diff --git a/packages/db/src/migrations/0103_routine_description_annotations.sql b/packages/db/src/migrations/0103_routine_description_annotations.sql new file mode 100644 index 00000000000..9c34612b618 --- /dev/null +++ b/packages/db/src/migrations/0103_routine_description_annotations.sql @@ -0,0 +1,154 @@ +CREATE TABLE IF NOT EXISTS "routine_documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "routine_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "key" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routine_documents_company_id_companies_id_fk' + ) THEN + ALTER TABLE "routine_documents" ADD CONSTRAINT "routine_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routine_documents_routine_id_routines_id_fk' + ) THEN + ALTER TABLE "routine_documents" ADD CONSTRAINT "routine_documents_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routine_documents_document_id_documents_id_fk' + ) THEN + ALTER TABLE "routine_documents" ADD CONSTRAINT "routine_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "routine_documents_company_routine_key_uq" ON "routine_documents" USING btree ("company_id","routine_id","key"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "routine_documents_document_uq" ON "routine_documents" USING btree ("document_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routine_documents_company_routine_updated_idx" ON "routine_documents" USING btree ("company_id","routine_id","updated_at"); +--> statement-breakpoint +ALTER TABLE "document_annotation_threads" ADD COLUMN IF NOT EXISTS "routine_id" uuid; +--> statement-breakpoint +ALTER TABLE "document_annotation_comments" ADD COLUMN IF NOT EXISTS "routine_id" uuid; +--> statement-breakpoint +ALTER TABLE "document_annotation_threads" ALTER COLUMN "issue_id" DROP NOT NULL; +--> statement-breakpoint +ALTER TABLE "document_annotation_comments" ALTER COLUMN "issue_id" DROP NOT NULL; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_annotation_threads_routine_id_routines_id_fk' + ) THEN + ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'document_annotation_comments_routine_id_routines_id_fk' + ) THEN + ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_routine_status_idx" ON "document_annotation_threads" USING btree ("company_id","routine_id","status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_routine_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","routine_id","created_at"); +--> statement-breakpoint +DO $$ +DECLARE + routine_row RECORD; + created_document_id uuid; + created_revision_id uuid; +BEGIN + FOR routine_row IN + SELECT * + FROM "routines" + WHERE NOT EXISTS ( + SELECT 1 + FROM "routine_documents" + WHERE "routine_documents"."routine_id" = "routines"."id" + AND "routine_documents"."key" = 'description' + ) + LOOP + INSERT INTO "documents" ( + "company_id", + "title", + "format", + "latest_body", + "latest_revision_number", + "created_by_agent_id", + "created_by_user_id", + "updated_by_agent_id", + "updated_by_user_id", + "created_at", + "updated_at" + ) + VALUES ( + routine_row."company_id", + 'routine description', + 'markdown', + coalesce(routine_row."description", ''), + 1, + routine_row."created_by_agent_id", + routine_row."created_by_user_id", + routine_row."updated_by_agent_id", + routine_row."updated_by_user_id", + coalesce(routine_row."created_at", now()), + coalesce(routine_row."updated_at", now()) + ) + RETURNING "id" INTO created_document_id; + + INSERT INTO "document_revisions" ( + "company_id", + "document_id", + "revision_number", + "title", + "format", + "body", + "change_summary", + "created_by_agent_id", + "created_by_user_id", + "created_at" + ) + VALUES ( + routine_row."company_id", + created_document_id, + 1, + 'routine description', + 'markdown', + coalesce(routine_row."description", ''), + 'Backfilled routine description', + routine_row."created_by_agent_id", + routine_row."created_by_user_id", + coalesce(routine_row."created_at", now()) + ) + RETURNING "id" INTO created_revision_id; + + UPDATE "documents" + SET "latest_revision_id" = created_revision_id + WHERE "id" = created_document_id; + + INSERT INTO "routine_documents" ("company_id", "routine_id", "document_id", "key", "created_at", "updated_at") + VALUES ( + routine_row."company_id", + routine_row."id", + created_document_id, + 'description', + coalesce(routine_row."created_at", now()), + coalesce(routine_row."updated_at", now()) + ) + ON CONFLICT DO NOTHING; + END LOOP; +END $$; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index b9374e1ffc9..ebd29a1e639 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -722,6 +722,13 @@ "when": 1781490000001, "tag": "0102_workspace_operations_issue_id", "breakpoints": true + }, + { + "idx": 103, + "version": "7", + "when": 1781490000002, + "tag": "0103_routine_description_annotations", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema/document_annotation_comments.ts b/packages/db/src/schema/document_annotation_comments.ts index e547c4508df..05214e9b021 100644 --- a/packages/db/src/schema/document_annotation_comments.ts +++ b/packages/db/src/schema/document_annotation_comments.ts @@ -7,6 +7,7 @@ import { documents } from "./documents.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; import { issueComments } from "./issue_comments.js"; import { issues } from "./issues.js"; +import { routines } from "./routines.js"; export const documentAnnotationComments = pgTable( "document_annotation_comments", @@ -14,7 +15,8 @@ export const documentAnnotationComments = pgTable( id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id), threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }), - issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }), + routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }), documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), body: text("body").notNull(), authorType: text("author_type").$type().notNull(), @@ -36,6 +38,11 @@ export const documentAnnotationComments = pgTable( table.issueId, table.createdAt, ), + companyRoutineCreatedAtIdx: index("document_annotation_comments_company_routine_created_at_idx").on( + table.companyId, + table.routineId, + table.createdAt, + ), companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on( table.companyId, table.documentId, diff --git a/packages/db/src/schema/document_annotation_threads.ts b/packages/db/src/schema/document_annotation_threads.ts index c040fbb5cfd..a856c647b41 100644 --- a/packages/db/src/schema/document_annotation_threads.ts +++ b/packages/db/src/schema/document_annotation_threads.ts @@ -10,13 +10,15 @@ import { companies } from "./companies.js"; import { documentRevisions } from "./document_revisions.js"; import { documents } from "./documents.js"; import { issues } from "./issues.js"; +import { routines } from "./routines.js"; export const documentAnnotationThreads = pgTable( "document_annotation_threads", { id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id), - issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }), + routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }), documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), documentKey: text("document_key").notNull(), status: text("status").$type().notNull().default("open"), @@ -56,6 +58,11 @@ export const documentAnnotationThreads = pgTable( table.issueId, table.status, ), + companyRoutineStatusIdx: index("document_annotation_threads_company_routine_status_idx").on( + table.companyId, + table.routineId, + table.status, + ), companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on( table.companyId, table.documentId, diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 2dfc4f7d866..51a75d59e6b 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -56,6 +56,7 @@ export { issueAttachments } from "./issue_attachments.js"; export { documents } from "./documents.js"; export { documentRevisions } from "./document_revisions.js"; export { issueDocuments } from "./issue_documents.js"; +export { routineDocuments } from "./routine_documents.js"; export { documentAnnotationThreads } from "./document_annotation_threads.js"; export { documentAnnotationComments } from "./document_annotation_comments.js"; export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js"; diff --git a/packages/db/src/schema/routine_documents.ts b/packages/db/src/schema/routine_documents.ts new file mode 100644 index 00000000000..926a89e84ba --- /dev/null +++ b/packages/db/src/schema/routine_documents.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { documents } from "./documents.js"; +import { routines } from "./routines.js"; + +export const routineDocuments = pgTable( + "routine_documents", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + routineId: uuid("routine_id").notNull().references(() => routines.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + key: text("key").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyRoutineKeyUq: uniqueIndex("routine_documents_company_routine_key_uq").on( + table.companyId, + table.routineId, + table.key, + ), + documentUq: uniqueIndex("routine_documents_document_uq").on(table.documentId), + companyRoutineUpdatedIdx: index("routine_documents_company_routine_updated_idx").on( + table.companyId, + table.routineId, + table.updatedAt, + ), + }), +); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 76ba039ae66..e9d8e93b1fa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -741,6 +741,7 @@ export type { Routine, RoutineEnvConfig, RoutineManagedByPlugin, + RoutineDescriptionDocument, RoutineVariable, RoutineVariableDefaultValue, RoutineRevisionSnapshotRoutineV1, diff --git a/packages/shared/src/types/document-annotation.ts b/packages/shared/src/types/document-annotation.ts index a291707f4e7..ec975a954b9 100644 --- a/packages/shared/src/types/document-annotation.ts +++ b/packages/shared/src/types/document-annotation.ts @@ -55,7 +55,8 @@ export interface DocumentAnnotationAnchorSnapshot { export interface DocumentAnnotationThread { id: string; companyId: string; - issueId: string; + issueId: string | null; + routineId?: string | null; documentId: string; documentKey: string; status: DocumentAnnotationThreadStatus; @@ -86,7 +87,8 @@ export interface DocumentAnnotationComment { id: string; companyId: string; threadId: string; - issueId: string; + issueId: string | null; + routineId?: string | null; documentId: string; body: string; authorType: IssueCommentAuthorType; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index ffc265520c3..08ab322c13d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -418,6 +418,7 @@ export type { Routine, RoutineEnvConfig, RoutineManagedByPlugin, + RoutineDescriptionDocument, RoutineVariable, RoutineVariableDefaultValue, RoutineRevisionSnapshotRoutineV1, diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index 3e87715300b..a252232ef6c 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -10,6 +10,24 @@ import type { } from "../constants.js"; import type { EnvBinding } from "./secrets.js"; +export interface RoutineDescriptionDocument { + id: string; + companyId: string; + routineId: string; + key: "description"; + title: string | null; + format: "markdown"; + body: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + createdByAgentId: string | null; + createdByUserId: string | null; + updatedByAgentId: string | null; + updatedByUserId: string | null; + createdAt: Date; + updatedAt: Date; +} + export interface RoutineProjectSummary { id: string; name: string; @@ -195,6 +213,7 @@ export interface RoutineDetail extends Routine { project: RoutineProjectSummary | null; assignee: RoutineAgentSummary | null; parentIssue: RoutineIssueSummary | null; + descriptionDocument?: RoutineDescriptionDocument | null; triggers: RoutineTrigger[]; recentRuns: RoutineRunSummary[]; activeIssue: RoutineIssueSummary | null; diff --git a/server/src/__tests__/routine-document-annotation-routes.test.ts b/server/src/__tests__/routine-document-annotation-routes.test.ts new file mode 100644 index 00000000000..66d791f2904 --- /dev/null +++ b/server/src/__tests__/routine-document-annotation-routes.test.ts @@ -0,0 +1,302 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const routineId = "11111111-1111-4111-8111-111111111111"; +const companyId = "22222222-2222-4222-8222-222222222222"; +const otherCompanyId = "33333333-3333-4333-8333-333333333333"; +const agentId = "77777777-7777-4777-8777-777777777777"; +const firstRevisionId = "44444444-4444-4444-8444-444444444444"; +const secondRevisionId = "99999999-9999-4999-8999-999999999999"; + +const mockRoutineService = vi.hoisted(() => ({ + get: vi.fn(), + getDetail: vi.fn(), + getDescriptionDocument: vi.fn(), + update: vi.fn(), +})); +const mockAnnotationService = vi.hoisted(() => ({ + listThreadsForRoutineDocument: vi.fn(), + getThreadForRoutineDocument: vi.fn(), + createRoutineThread: vi.fn(), + addRoutineComment: vi.fn(), + updateRoutineThread: vi.fn(), + remapOpenThreadsForRoutineDocument: vi.fn(), +})); +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +const routine = { + id: routineId, + companyId, + title: "Daily summary", + description: "Alpha selected text omega", + status: "active", + assigneeAgentId: agentId, + latestRevisionId: firstRevisionId, + latestRevisionNumber: 1, +}; + +const descriptionDocument = { + id: "document-1", + companyId, + routineId, + key: "description", + title: "Routine instructions", + format: "markdown", + body: "Alpha selected text omega", + latestRevisionId: firstRevisionId, + latestRevisionNumber: 1, + createdByAgentId: null, + createdByUserId: "board-user", + updatedByAgentId: null, + updatedByUserId: "board-user", + createdAt: new Date("2026-06-16T12:00:00.000Z"), + updatedAt: new Date("2026-06-16T12:00:00.000Z"), +}; + +const updatedDescriptionDocument = { + ...descriptionDocument, + body: "Alpha updated selected text omega", + latestRevisionId: secondRevisionId, + latestRevisionNumber: 2, + updatedAt: new Date("2026-06-16T12:02:00.000Z"), +}; + +const selector = { + quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" }, + position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 }, +}; + +const annotationThread = { + id: "55555555-5555-4555-8555-555555555555", + companyId, + issueId: null, + routineId, + documentId: descriptionDocument.id, + documentKey: "description", + status: "open", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: firstRevisionId, + originalRevisionNumber: 1, + currentRevisionId: firstRevisionId, + currentRevisionNumber: 1, + selectedText: "selected text", + prefixText: "Alpha ", + suffixText: " omega", + normalizedStart: 6, + normalizedEnd: 19, + markdownStart: 6, + markdownEnd: 19, + anchorSelector: selector, + createdByAgentId: null, + createdByUserId: "board-user", + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, + createdAt: new Date("2026-06-16T12:01:00.000Z"), + updatedAt: new Date("2026-06-16T12:01:00.000Z"), +}; + +const annotationComment = { + id: "66666666-6666-4666-8666-666666666666", + companyId, + threadId: annotationThread.id, + issueId: null, + routineId, + documentId: descriptionDocument.id, + body: "Please tighten this", + authorType: "user", + authorAgentId: null, + authorUserId: "board-user", + createdByRunId: null, + createdAt: new Date("2026-06-16T12:01:00.000Z"), + updatedAt: new Date("2026-06-16T12:01:00.000Z"), +}; + +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => true), + }), + documentAnnotationService: () => mockAnnotationService, + logActivity: mockLogActivity, + routineService: () => mockRoutineService, + })); +} + +async function createApp(actor: "board" | "agent" = "board", actorCompanyId = companyId) { + const [{ routineRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/routines.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor === "agent" + ? { + type: "agent", + agentId, + companyId: actorCompanyId, + runId: "88888888-8888-4888-8888-888888888888", + } + : { + type: "board", + userId: "board-user", + companyIds: [actorCompanyId], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", routineRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("routine description annotation routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../routes/routines.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.clearAllMocks(); + + mockRoutineService.get.mockResolvedValue(routine); + mockRoutineService.getDescriptionDocument.mockResolvedValue(updatedDescriptionDocument); + mockRoutineService.update.mockResolvedValue({ + ...routine, + description: "Alpha updated selected text omega", + latestRevisionId: secondRevisionId, + latestRevisionNumber: 2, + }); + mockAnnotationService.listThreadsForRoutineDocument.mockImplementation(async ( + _routineId: string, + _key: string, + options?: { includeComments?: boolean }, + ) => ( + options?.includeComments + ? [{ ...annotationThread, comments: [annotationComment] }] + : [annotationThread] + )); + mockAnnotationService.getThreadForRoutineDocument.mockResolvedValue({ + ...annotationThread, + comments: [annotationComment], + }); + mockAnnotationService.createRoutineThread.mockResolvedValue({ + ...annotationThread, + comments: [annotationComment], + }); + mockAnnotationService.addRoutineComment.mockResolvedValue(annotationComment); + mockAnnotationService.updateRoutineThread.mockResolvedValue({ ...annotationThread, status: "resolved" }); + mockAnnotationService.remapOpenThreadsForRoutineDocument.mockResolvedValue([ + { + thread: { + ...annotationThread, + currentRevisionId: secondRevisionId, + currentRevisionNumber: 2, + }, + snapshot: { id: "snapshot-1" }, + }, + ]); + }); + + it("creates, replies to, resolves, and reopens routine description annotation threads", async () => { + const app = await createApp(); + + const created = await request(app) + .post(`/api/routines/${routineId}/description/annotations`) + .send({ + baseRevisionId: firstRevisionId, + baseRevisionNumber: 1, + selector, + body: "Please tighten this", + }) + .expect(201); + + expect(created.body.id).toBe(annotationThread.id); + expect(mockAnnotationService.createRoutineThread).toHaveBeenCalledWith( + routineId, + "description", + expect.objectContaining({ baseRevisionId: firstRevisionId, body: "Please tighten this" }), + expect.objectContaining({ actorType: "user", userId: "board-user" }), + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "routine.document_annotation_thread_created", + entityType: "routine", + entityId: routineId, + details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }), + })); + + await request(app) + .post(`/api/routines/${routineId}/description/annotations/${annotationThread.id}/comments`) + .send({ body: "Reply on the same thread" }) + .expect(201); + expect(mockAnnotationService.addRoutineComment).toHaveBeenCalledWith( + routineId, + "description", + annotationThread.id, + expect.objectContaining({ body: "Reply on the same thread" }), + expect.objectContaining({ actorType: "user", userId: "board-user" }), + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "routine.document_annotation_comment_added", + details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }), + })); + + await request(app) + .patch(`/api/routines/${routineId}/description/annotations/${annotationThread.id}`) + .send({ status: "resolved" }) + .expect(200); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "routine.document_annotation_thread_resolved", + details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }), + })); + + mockAnnotationService.updateRoutineThread.mockResolvedValueOnce({ ...annotationThread, status: "open" }); + await request(app) + .patch(`/api/routines/${routineId}/description/annotations/${annotationThread.id}`) + .send({ status: "open" }) + .expect(200); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "routine.document_annotation_thread_reopened", + details: expect.objectContaining({ documentKey: "description", threadId: annotationThread.id }), + })); + }); + + it("remaps open routine description annotations after routine description revisions", async () => { + const updated = await request(await createApp()) + .patch(`/api/routines/${routineId}`) + .send({ + description: "Alpha updated selected text omega", + baseRevisionId: firstRevisionId, + }) + .expect(200); + + expect(updated.body.latestRevisionNumber).toBe(2); + expect(mockRoutineService.getDescriptionDocument).toHaveBeenCalledWith(routineId); + expect(mockAnnotationService.remapOpenThreadsForRoutineDocument).toHaveBeenCalledWith({ + routineId, + key: "description", + documentId: updatedDescriptionDocument.id, + nextRevisionId: updatedDescriptionDocument.latestRevisionId, + nextRevisionNumber: updatedDescriptionDocument.latestRevisionNumber, + nextBody: updatedDescriptionDocument.body, + }); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "routine.document_annotation_remapped", + entityType: "routine", + entityId: routineId, + details: expect.objectContaining({ + documentKey: "description", + threadId: annotationThread.id, + }), + })); + }); + + it("rejects agent cross-company routine annotation reads", async () => { + await request(await createApp("agent", otherCompanyId)) + .get(`/api/routines/${routineId}/description/annotations`) + .expect(403); + }); +}); diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index d541f0cf909..3aa806f4097 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -101,6 +101,7 @@ const mockRoutineService = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), getDetail: vi.fn(), + getDescriptionDocument: vi.fn(), update: vi.fn(), create: vi.fn(), listRevisions: vi.fn(), @@ -115,6 +116,15 @@ const mockRoutineService = vi.hoisted(() => ({ firePublicTrigger: vi.fn(), })); +const mockAnnotationService = vi.hoisted(() => ({ + listThreadsForRoutineDocument: vi.fn(), + getThreadForRoutineDocument: vi.fn(), + createRoutineThread: vi.fn(), + addRoutineComment: vi.fn(), + updateRoutineThread: vi.fn(), + remapOpenThreadsForRoutineDocument: vi.fn(), +})); + const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), })); @@ -149,6 +159,7 @@ function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, + documentAnnotationService: () => mockAnnotationService, logActivity: mockLogActivity, routineService: () => mockRoutineService, })); @@ -205,6 +216,96 @@ describe("routine routes", () => { }); mockAccessService.canUser.mockResolvedValue(false); mockLogActivity.mockResolvedValue(undefined); + mockRoutineService.getDescriptionDocument.mockResolvedValue({ + id: "99999999-9999-4999-8999-999999999999", + companyId, + routineId, + key: "description", + title: "Routine description", + format: "markdown", + body: "Alpha selected text omega", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + createdAt: new Date("2026-03-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-20T00:00:00.000Z"), + }); + mockAnnotationService.listThreadsForRoutineDocument.mockResolvedValue([]); + mockAnnotationService.getThreadForRoutineDocument.mockResolvedValue(null); + const annotationThread = { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId, + issueId: null, + routineId, + documentId: "99999999-9999-4999-8999-999999999999", + documentKey: "description", + status: "open", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: revisionId, + originalRevisionNumber: 1, + currentRevisionId: revisionId, + currentRevisionNumber: 1, + selectedText: "selected text", + prefixText: "Alpha ", + suffixText: " omega", + normalizedStart: 6, + normalizedEnd: 19, + markdownStart: 6, + markdownEnd: 19, + anchorSelector: { + quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" }, + position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 }, + }, + createdByAgentId: null, + createdByUserId: "board-user", + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, + createdAt: new Date("2026-03-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-20T00:00:00.000Z"), + comments: [{ + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + companyId, + threadId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + issueId: null, + routineId, + documentId: "99999999-9999-4999-8999-999999999999", + body: "Please review", + authorType: "user", + authorAgentId: null, + authorUserId: "board-user", + createdByRunId: null, + issueCommentId: null, + createdAt: new Date("2026-03-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-20T00:00:00.000Z"), + }], + }; + mockAnnotationService.createRoutineThread.mockResolvedValue(annotationThread); + mockAnnotationService.addRoutineComment.mockResolvedValue({ + id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + companyId, + threadId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + issueId: null, + routineId, + documentId: "99999999-9999-4999-8999-999999999999", + body: "Reply", + authorType: "user", + authorAgentId: null, + authorUserId: "board-user", + createdByRunId: null, + issueCommentId: null, + createdAt: new Date("2026-03-20T00:00:00.000Z"), + updatedAt: new Date("2026-03-20T00:00:00.000Z"), + }); + mockAnnotationService.updateRoutineThread.mockResolvedValue({ + ...annotationThread, + status: "resolved", + }); + mockAnnotationService.remapOpenThreadsForRoutineDocument.mockResolvedValue([]); }); it("passes project filters to the routine list service", async () => { @@ -240,6 +341,86 @@ describe("routine routes", () => { expect(res.body[0]).toMatchObject({ id: revisionId, revisionNumber: 1 }); }); + it("creates, replies to, and resolves routine description annotation threads", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const selector = { + quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" }, + position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 }, + }; + + const created = await request(app) + .post(`/api/routines/${routineId}/description/annotations`) + .send({ + baseRevisionId: revisionId, + baseRevisionNumber: 1, + selector, + body: "Please review", + }) + .expect(201); + + expect(created.body).toMatchObject({ + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + routineId, + issueId: null, + documentKey: "description", + }); + expect(mockAnnotationService.createRoutineThread).toHaveBeenCalledWith( + routineId, + "description", + expect.objectContaining({ body: "Please review" }), + expect.objectContaining({ actorType: "user", userId: "board-user" }), + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "routine.document_annotation_thread_created", + entityType: "routine", + entityId: routineId, + details: expect.objectContaining({ documentKey: "description" }), + })); + + await request(app) + .post(`/api/routines/${routineId}/description/annotations/${created.body.id}/comments`) + .send({ body: "Reply" }) + .expect(201); + expect(mockAnnotationService.addRoutineComment).toHaveBeenCalledWith( + routineId, + "description", + created.body.id, + expect.objectContaining({ body: "Reply" }), + expect.objectContaining({ actorType: "user", userId: "board-user" }), + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "routine.document_annotation_comment_added", + entityType: "routine", + entityId: routineId, + })); + + const resolved = await request(app) + .patch(`/api/routines/${routineId}/description/annotations/${created.body.id}`) + .send({ status: "resolved" }) + .expect(200); + + expect(resolved.body.status).toBe("resolved"); + expect(mockAnnotationService.updateRoutineThread).toHaveBeenCalledWith( + routineId, + "description", + created.body.id, + expect.objectContaining({ status: "resolved" }), + expect.objectContaining({ actorType: "user", userId: "board-user" }), + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "routine.document_annotation_thread_resolved", + entityType: "routine", + entityId: routineId, + })); + }); + it("blocks routine revision reads across company scope", async () => { const app = await createApp({ type: "board", diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index d5d9d35f082..0a16df2465e 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -2,15 +2,18 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { createRoutineSchema, + createDocumentAnnotationCommentSchema, + createDocumentAnnotationThreadSchema, createRoutineTriggerSchema, rotateRoutineTriggerSecretSchema, runRoutineSchema, + updateDocumentAnnotationThreadSchema, updateRoutineSchema, updateRoutineTriggerSchema, } from "@paperclipai/shared"; import { trackRoutineCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; -import { accessService, logActivity, routineService } from "../services/index.js"; +import { accessService, documentAnnotationService, logActivity, routineService } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { forbidden, unauthorized } from "../errors.js"; import { getTelemetryClient } from "../telemetry.js"; @@ -24,7 +27,63 @@ export function routineRoutes( const svc = routineService(db, { pluginWorkerManager: options.pluginWorkerManager, }); + const documentAnnotationsSvc = documentAnnotationService(db); const access = accessService(db); + const routineDocumentKey = "description"; + + function parseBooleanQuery(value: unknown) { + return value === true || value === "true" || value === "1"; + } + + function annotationActorInput(req: Request) { + const actor = getActorInfo(req); + return { + actor, + annotationActor: { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId, + }, + }; + } + + async function remapRoutineDescriptionAnnotations(req: Request, routineId: string) { + const doc = await svc.getDescriptionDocument(routineId); + if (!doc) return; + const remapped = await documentAnnotationsSvc.remapOpenThreadsForRoutineDocument({ + routineId, + key: routineDocumentKey, + documentId: doc.id, + nextRevisionId: doc.latestRevisionId, + nextRevisionNumber: doc.latestRevisionNumber, + nextBody: doc.body, + }); + const actor = getActorInfo(req); + for (const remap of remapped) { + await logActivity(db, { + companyId: doc.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "routine.document_annotation_remapped", + entityType: "routine", + entityId: routineId, + details: { + key: doc.key, + documentKey: doc.key, + documentId: doc.id, + threadId: remap.thread.id, + revisionNumber: doc.latestRevisionNumber, + anchorState: remap.thread.anchorState, + anchorConfidence: remap.thread.anchorConfidence, + snapshotId: remap.snapshot.id, + }, + }); + } + } async function assertBoardCanAssignTasks(req: Request, companyId: string) { assertCompanyAccess(req, companyId); @@ -149,6 +208,156 @@ export function routineRoutes( res.json(revisions); }); + router.get("/routines/:id/description/annotations", async (req, res) => { + const routine = await assertCanManageExistingRoutine(req, req.params.id as string); + if (!routine) { + res.status(404).json({ error: "Routine not found" }); + return; + } + const status = req.query.status === "resolved" || req.query.status === "all" ? req.query.status : "open"; + const threads = await documentAnnotationsSvc.listThreadsForRoutineDocument(routine.id, routineDocumentKey, { + status, + includeComments: parseBooleanQuery(req.query.includeComments), + }); + res.json(threads); + }); + + router.get("/routines/:id/description/annotations/:threadId", async (req, res) => { + const routine = await assertCanManageExistingRoutine(req, req.params.id as string); + if (!routine) { + res.status(404).json({ error: "Routine not found" }); + return; + } + const thread = await documentAnnotationsSvc.getThreadForRoutineDocument( + routine.id, + routineDocumentKey, + req.params.threadId as string, + ); + if (!thread) { + res.status(404).json({ error: "Annotation thread not found" }); + return; + } + res.json(thread); + }); + + router.post( + "/routines/:id/description/annotations", + validate(createDocumentAnnotationThreadSchema), + async (req, res) => { + const routine = await assertCanManageExistingRoutine(req, req.params.id as string); + if (!routine) { + res.status(404).json({ error: "Routine not found" }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const thread = await documentAnnotationsSvc.createRoutineThread( + routine.id, + routineDocumentKey, + req.body, + annotationActor, + ); + const firstComment = thread.comments[0]; + await logActivity(db, { + companyId: routine.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "routine.document_annotation_thread_created", + entityType: "routine", + entityId: routine.id, + details: { + key: thread.documentKey, + documentKey: thread.documentKey, + documentId: thread.documentId, + threadId: thread.id, + commentId: firstComment?.id ?? null, + revisionNumber: thread.currentRevisionNumber, + quote: thread.selectedText.slice(0, 240), + }, + }); + res.status(201).json(thread); + }, + ); + + router.post( + "/routines/:id/description/annotations/:threadId/comments", + validate(createDocumentAnnotationCommentSchema), + async (req, res) => { + const routine = await assertCanManageExistingRoutine(req, req.params.id as string); + if (!routine) { + res.status(404).json({ error: "Routine not found" }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const comment = await documentAnnotationsSvc.addRoutineComment( + routine.id, + routineDocumentKey, + req.params.threadId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: routine.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "routine.document_annotation_comment_added", + entityType: "routine", + entityId: routine.id, + details: { + key: routineDocumentKey, + documentKey: routineDocumentKey, + threadId: comment.threadId, + commentId: comment.id, + bodySnippet: comment.body.slice(0, 120), + }, + }); + res.status(201).json(comment); + }, + ); + + router.patch( + "/routines/:id/description/annotations/:threadId", + validate(updateDocumentAnnotationThreadSchema), + async (req, res) => { + const routine = await assertCanManageExistingRoutine(req, req.params.id as string); + if (!routine) { + res.status(404).json({ error: "Routine not found" }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const thread = await documentAnnotationsSvc.updateRoutineThread( + routine.id, + routineDocumentKey, + req.params.threadId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: routine.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: thread.status === "resolved" + ? "routine.document_annotation_thread_resolved" + : "routine.document_annotation_thread_reopened", + entityType: "routine", + entityId: routine.id, + details: { + key: thread.documentKey, + documentKey: thread.documentKey, + documentId: thread.documentId, + threadId: thread.id, + status: thread.status, + }, + }); + res.json(thread); + }, + ); + router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => { const routine = await assertCanManageExistingRoutine(req, req.params.id as string); if (!routine) { @@ -193,6 +402,7 @@ export function routineRoutes( details: { title: updated?.title ?? routine.title }, }); if (updated && updated.latestRevisionId !== routine.latestRevisionId) { + await remapRoutineDescriptionAnnotations(req, routine.id); await logRoutineRevisionCreated(req, { companyId: routine.companyId, routineId: routine.id, @@ -235,6 +445,7 @@ export function routineRoutes( triggerCount: result.revision.snapshot.triggers.length, }, }); + await remapRoutineDescriptionAnnotations(req, routine.id); res.json(result); }); diff --git a/server/src/services/document-annotations.ts b/server/src/services/document-annotations.ts index d9fa9bfe38c..f98ce426860 100644 --- a/server/src/services/document-annotations.ts +++ b/server/src/services/document-annotations.ts @@ -7,6 +7,7 @@ import { documents, issueComments, issueDocuments, + routineDocuments, } from "@paperclipai/db"; import { anchorSnapshotToSelector, @@ -40,10 +41,21 @@ type IssueDocumentRow = { latestRevisionNumber: number; }; +type RoutineDocumentRow = { + routineId: string; + companyId: string; + documentId: string; + documentKey: string; + latestBody: string; + latestRevisionId: string | null; + latestRevisionNumber: number; +}; + const threadSelect = { id: documentAnnotationThreads.id, companyId: documentAnnotationThreads.companyId, issueId: documentAnnotationThreads.issueId, + routineId: documentAnnotationThreads.routineId, documentId: documentAnnotationThreads.documentId, documentKey: documentAnnotationThreads.documentKey, status: documentAnnotationThreads.status, @@ -75,6 +87,7 @@ const commentSelect = { companyId: documentAnnotationComments.companyId, threadId: documentAnnotationComments.threadId, issueId: documentAnnotationComments.issueId, + routineId: documentAnnotationComments.routineId, documentId: documentAnnotationComments.documentId, body: documentAnnotationComments.body, authorType: documentAnnotationComments.authorType, @@ -116,6 +129,27 @@ export function documentAnnotationService(db: Db) { .then((rows: IssueDocumentRow[]) => rows[0] ?? null); } + async function getRoutineDocument( + routineId: string, + key: string, + dbOrTx: any = db, + ): Promise { + return dbOrTx + .select({ + routineId: routineDocuments.routineId, + companyId: documents.companyId, + documentId: documents.id, + documentKey: routineDocuments.key, + latestBody: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + }) + .from(routineDocuments) + .innerJoin(documents, eq(routineDocuments.documentId, documents.id)) + .where(and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key))) + .then((rows: RoutineDocumentRow[]) => rows[0] ?? null); + } + async function getThreadForIssue( issueId: string, documentKey: string, @@ -133,6 +167,23 @@ export function documentAnnotationService(db: Db) { .then((rows: DocumentAnnotationThread[]) => rows[0] ?? null); } + async function getThreadForRoutine( + routineId: string, + documentKey: string, + threadId: string, + dbOrTx: any = db, + ): Promise { + return dbOrTx + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and( + eq(documentAnnotationThreads.id, threadId), + eq(documentAnnotationThreads.routineId, routineId), + eq(documentAnnotationThreads.documentKey, documentKey), + )) + .then((rows: DocumentAnnotationThread[]) => rows[0] ?? null); + } + async function commentsForThreads(threadIds: string[], dbOrTx: any = db): Promise { if (threadIds.length === 0) return []; return dbOrTx @@ -197,6 +248,39 @@ export function documentAnnotationService(db: Db) { })); }, + listThreadsForRoutineDocument: async ( + routineId: string, + key: string, + options: { status?: "open" | "resolved" | "all"; includeComments?: boolean } = {}, + ) => { + const doc = await getRoutineDocument(routineId, key); + if (!doc) throw notFound("Document not found"); + const conditions = [ + eq(documentAnnotationThreads.routineId, routineId), + eq(documentAnnotationThreads.documentId, doc.documentId), + ]; + if (options.status && options.status !== "all") { + conditions.push(eq(documentAnnotationThreads.status, options.status)); + } + const threads: DocumentAnnotationThread[] = await db + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and(...conditions)) + .orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id)); + if (!options.includeComments) return threads; + const comments = await commentsForThreads(threads.map((thread) => thread.id)); + const commentsByThread = new Map(); + for (const comment of comments) { + const existing = commentsByThread.get(comment.threadId) ?? []; + existing.push(comment); + commentsByThread.set(comment.threadId, existing); + } + return threads.map((thread) => ({ + ...thread, + comments: commentsByThread.get(thread.id) ?? [], + })); + }, + getThreadForIssueDocument: async (issueId: string, key: string, threadId: string) => { const thread = await getThreadForIssue(issueId, key, threadId); if (!thread) return null; @@ -204,6 +288,13 @@ export function documentAnnotationService(db: Db) { return { ...thread, comments }; }, + getThreadForRoutineDocument: async (routineId: string, key: string, threadId: string) => { + const thread = await getThreadForRoutine(routineId, key, threadId); + if (!thread) return null; + const comments = await commentsForThreads([thread.id]); + return { ...thread, comments }; + }, + createThread: async ( issueId: string, key: string, @@ -291,6 +382,94 @@ export function documentAnnotationService(db: Db) { return { ...thread, comments: [comment] }; }), + createRoutineThread: async ( + routineId: string, + key: string, + input: CreateDocumentAnnotationThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + await tx.execute(sql` + select ${documents.id} + from ${routineDocuments} + inner join ${documents} on ${routineDocuments.documentId} = ${documents.id} + where ${and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key))} + for update of ${documents} + `); + const doc = await getRoutineDocument(routineId, key, tx); + if (!doc) throw notFound("Document not found"); + if ( + input.baseRevisionId !== doc.latestRevisionId + || input.baseRevisionNumber !== doc.latestRevisionNumber + ) { + throw conflict("Annotation anchor requires the current document revision", { + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + }); + } + + const verification = verifyDocumentAnchorSelector({ + markdown: doc.latestBody, + selector: input.selector, + }); + if (!verification.ok || !verification.anchor) { + throw unprocessable("Annotation anchor does not match the current document revision", { + reason: verification.reason, + }); + } + + const now = new Date(); + const [thread] = await tx + .insert(documentAnnotationThreads) + .values({ + companyId: doc.companyId, + issueId: null, + routineId, + documentId: doc.documentId, + documentKey: doc.documentKey, + status: "open", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: doc.latestRevisionId, + originalRevisionNumber: doc.latestRevisionNumber, + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + selectedText: verification.anchor.selectedText, + prefixText: verification.anchor.prefixText, + suffixText: verification.anchor.suffixText, + normalizedStart: verification.anchor.normalizedStart, + normalizedEnd: verification.anchor.normalizedEnd, + markdownStart: verification.anchor.markdownStart, + markdownEnd: verification.anchor.markdownEnd, + anchorSelector: input.selector, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(threadSelect); + + const [comment] = await tx + .insert(documentAnnotationComments) + .values({ + companyId: doc.companyId, + threadId: thread.id, + issueId: null, + routineId, + documentId: doc.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: null, + createdAt: now, + updatedAt: now, + }) + .returning(commentSelect); + + return { ...thread, comments: [comment] }; + }), + addComment: async ( issueId: string, key: string, @@ -326,6 +505,41 @@ export function documentAnnotationService(db: Db) { return comment; }), + addRoutineComment: async ( + routineId: string, + key: string, + threadId: string, + input: CreateDocumentAnnotationComment, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const thread = await getThreadForRoutine(routineId, key, threadId, tx); + if (!thread) throw notFound("Annotation thread not found"); + const now = new Date(); + const [comment] = await tx + .insert(documentAnnotationComments) + .values({ + companyId: thread.companyId, + threadId: thread.id, + issueId: null, + routineId: thread.routineId, + documentId: thread.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: null, + createdAt: now, + updatedAt: now, + }) + .returning(commentSelect); + await tx + .update(documentAnnotationThreads) + .set({ updatedAt: now }) + .where(eq(documentAnnotationThreads.id, thread.id)); + return comment; + }), + cleanupForIssueCommentDeletion: async ( issueId: string, issueCommentId: string, @@ -425,6 +639,40 @@ export function documentAnnotationService(db: Db) { return updated; }), + updateRoutineThread: async ( + routineId: string, + key: string, + threadId: string, + input: UpdateDocumentAnnotationThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const thread = await getThreadForRoutine(routineId, key, threadId, tx); + if (!thread) throw notFound("Annotation thread not found"); + if (!input.status || input.status === thread.status) return thread; + + const now = new Date(); + const [updated] = await tx + .update(documentAnnotationThreads) + .set(input.status === "resolved" + ? { + status: "resolved", + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + } + : { + status: "open", + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, + updatedAt: now, + }) + .where(eq(documentAnnotationThreads.id, thread.id)) + .returning(threadSelect); + return updated; + }), + remapOpenThreadsForDocument: async (input: { issueId: string; key: string; @@ -500,6 +748,81 @@ export function documentAnnotationService(db: Db) { return changed; }), + remapOpenThreadsForRoutineDocument: async (input: { + routineId: string; + key: string; + documentId: string; + nextRevisionId: string | null; + nextRevisionNumber: number; + nextBody: string; + }) => db.transaction(async (tx) => { + const threads: DocumentAnnotationThread[] = await tx + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and( + eq(documentAnnotationThreads.routineId, input.routineId), + eq(documentAnnotationThreads.documentId, input.documentId), + eq(documentAnnotationThreads.status, "open"), + )); + const changed = []; + const now = new Date(); + + for (const thread of threads) { + if (thread.currentRevisionId === input.nextRevisionId) continue; + const previousAnchor = snapshotFromThread(thread); + const remap = remapDocumentAnchor({ + previousAnchor, + nextMarkdown: input.nextBody, + }); + const nextAnchor = remap.anchor; + const nextSelector = nextAnchor ? anchorSnapshotToSelector(nextAnchor) : thread.anchorSelector; + const [updated] = await tx + .update(documentAnnotationThreads) + .set({ + currentRevisionId: input.nextRevisionId, + currentRevisionNumber: input.nextRevisionNumber, + anchorState: remap.anchorState, + anchorConfidence: remap.confidence, + ...(nextAnchor + ? { + selectedText: nextAnchor.selectedText, + prefixText: nextAnchor.prefixText, + suffixText: nextAnchor.suffixText, + normalizedStart: nextAnchor.normalizedStart, + normalizedEnd: nextAnchor.normalizedEnd, + markdownStart: nextAnchor.markdownStart, + markdownEnd: nextAnchor.markdownEnd, + } + : {}), + anchorSelector: nextSelector, + updatedAt: now, + }) + .where(eq(documentAnnotationThreads.id, thread.id)) + .returning(threadSelect); + const [snapshot] = await tx + .insert(documentAnnotationAnchorSnapshots) + .values({ + companyId: thread.companyId, + threadId: thread.id, + documentId: thread.documentId, + fromRevisionId: thread.currentRevisionId, + fromRevisionNumber: thread.currentRevisionNumber, + toRevisionId: input.nextRevisionId, + toRevisionNumber: input.nextRevisionNumber, + previousAnchor, + nextAnchor, + anchorState: remap.anchorState, + anchorConfidence: remap.confidence, + failureReason: remap.anchor ? null : remap.reason, + createdAt: now, + }) + .returning(); + changed.push({ thread: updated, snapshot }); + } + + return changed; + }), + selectorToAnchorSnapshot, }; } diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index f73363681df..f0147197def 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -6,6 +6,8 @@ import { companySecretBindings, companySecretVersions, companySecrets, + documentRevisions, + documents, executionWorkspaces, goals, heartbeatRuns, @@ -17,6 +19,7 @@ import { projects, routineRevisions, routineRuns, + routineDocuments, routines, routineTriggers, } from "@paperclipai/db"; @@ -25,6 +28,7 @@ import type { CreateRoutineTrigger, Routine, RoutineDetail, + RoutineDescriptionDocument, RoutineListItem, RoutineManagedByPlugin, RoutineRevision, @@ -80,6 +84,8 @@ type Actor = { agentId?: string | null; userId?: string | null; runId?: string | type RoutineRow = typeof routines.$inferSelect; type RoutineTriggerRow = typeof routineTriggers.$inferSelect; +const ROUTINE_DESCRIPTION_DOCUMENT_KEY = "description" as const; + interface RoutineTriggerSecretRestoreMaterial extends RoutineTriggerSecretMaterial { triggerId: string; } @@ -488,6 +494,42 @@ function mapRoutineRevision(row: typeof routineRevisions.$inferSelect): RoutineR }; } +function mapRoutineDescriptionDocument(row: { + id: string; + companyId: string; + routineId: string; + key: string; + title: string | null; + format: string; + latestBody: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + createdByAgentId: string | null; + createdByUserId: string | null; + updatedByAgentId: string | null; + updatedByUserId: string | null; + createdAt: Date; + updatedAt: Date; +}): RoutineDescriptionDocument { + return { + id: row.id, + companyId: row.companyId, + routineId: row.routineId, + key: ROUTINE_DESCRIPTION_DOCUMENT_KEY, + title: row.title, + format: "markdown", + body: row.latestBody, + latestRevisionId: row.latestRevisionId, + latestRevisionNumber: row.latestRevisionNumber, + createdByAgentId: row.createdByAgentId, + createdByUserId: row.createdByUserId, + updatedByAgentId: row.updatedByAgentId, + updatedByUserId: row.updatedByUserId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + export function routineService( db: Db, deps: { @@ -574,6 +616,163 @@ export function routineService( .then((rows) => rows[0] ?? null); } + async function getRoutineDescriptionDocument( + routineId: string, + executor: Db | any = db, + ): Promise { + const row = await executor + .select({ + id: documents.id, + companyId: documents.companyId, + routineId: routineDocuments.routineId, + key: routineDocuments.key, + title: documents.title, + format: documents.format, + latestBody: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + createdByAgentId: documents.createdByAgentId, + createdByUserId: documents.createdByUserId, + updatedByAgentId: documents.updatedByAgentId, + updatedByUserId: documents.updatedByUserId, + createdAt: documents.createdAt, + updatedAt: documents.updatedAt, + }) + .from(routineDocuments) + .innerJoin(documents, eq(routineDocuments.documentId, documents.id)) + .where(and( + eq(routineDocuments.routineId, routineId), + eq(routineDocuments.key, ROUTINE_DESCRIPTION_DOCUMENT_KEY), + )) + .then((rows: any[]) => rows[0] ?? null); + return row ? mapRoutineDescriptionDocument(row) : null; + } + + async function upsertRoutineDescriptionDocument( + executor: Db | any, + routine: RoutineRow, + actor: Actor, + options: { changeSummary?: string | null } = {}, + ): Promise { + const now = new Date(); + const body = routine.description ?? ""; + const existing = await getRoutineDescriptionDocument(routine.id, executor); + + if (existing) { + if (existing.body === body) return existing; + const nextRevisionNumber = existing.latestRevisionNumber + 1; + const [revision] = await executor + .insert(documentRevisions) + .values({ + companyId: routine.companyId, + documentId: existing.id, + revisionNumber: nextRevisionNumber, + title: "Routine description", + format: "markdown", + body, + changeSummary: options.changeSummary ?? null, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + createdAt: now, + }) + .returning(); + + await executor + .update(documents) + .set({ + title: "Routine description", + format: "markdown", + latestBody: body, + latestRevisionId: revision.id, + latestRevisionNumber: nextRevisionNumber, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + updatedAt: now, + }) + .where(eq(documents.id, existing.id)); + await executor + .update(routineDocuments) + .set({ updatedAt: now }) + .where(eq(routineDocuments.documentId, existing.id)); + + return { + ...existing, + title: "Routine description", + body, + latestRevisionId: revision.id, + latestRevisionNumber: nextRevisionNumber, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + updatedAt: now, + }; + } + + const [document] = await executor + .insert(documents) + .values({ + companyId: routine.companyId, + title: "Routine description", + format: "markdown", + latestBody: body, + latestRevisionId: null, + latestRevisionNumber: 1, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(); + const [revision] = await executor + .insert(documentRevisions) + .values({ + companyId: routine.companyId, + documentId: document.id, + revisionNumber: 1, + title: "Routine description", + format: "markdown", + body, + changeSummary: options.changeSummary ?? null, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + createdAt: now, + }) + .returning(); + await executor + .update(documents) + .set({ latestRevisionId: revision.id }) + .where(eq(documents.id, document.id)); + await executor.insert(routineDocuments).values({ + companyId: routine.companyId, + routineId: routine.id, + documentId: document.id, + key: ROUTINE_DESCRIPTION_DOCUMENT_KEY, + createdAt: now, + updatedAt: now, + }); + + return { + id: document.id, + companyId: routine.companyId, + routineId: routine.id, + key: ROUTINE_DESCRIPTION_DOCUMENT_KEY, + title: document.title, + format: "markdown", + body, + latestRevisionId: revision.id, + latestRevisionNumber: 1, + createdByAgentId: document.createdByAgentId, + createdByUserId: document.createdByUserId, + updatedByAgentId: document.updatedByAgentId, + updatedByUserId: document.updatedByUserId, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + }; + } + async function appendRoutineRevision( executor: Db, routine: RoutineRow, @@ -614,8 +813,18 @@ export function routineService( .where(eq(routines.id, routine.id)) .returning(); + const routineForDocument = updatedRoutine ?? { + ...routine, + latestRevisionId: revision.id, + latestRevisionNumber: nextRevisionNumber, + updatedAt: now, + }; + await upsertRoutineDescriptionDocument(executor, routineForDocument, actor, { + changeSummary: options.changeSummary ?? null, + }); + return { - routine: updatedRoutine ?? { ...routine, latestRevisionId: revision.id, latestRevisionNumber: nextRevisionNumber, updatedAt: now }, + routine: routineForDocument, revision: mapRoutineRevision(revision), }; } @@ -1488,7 +1697,7 @@ export function routineService( getDetail: async (id: string): Promise => { const row = await getRoutineById(id); if (!row) return null; - const [project, assignee, parentIssue, triggers, recentRuns, activeIssue, managedByRoutine] = await Promise.all([ + const [project, assignee, parentIssue, descriptionDocument, triggers, recentRuns, activeIssue, managedByRoutine] = await Promise.all([ row.projectId ? db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null) : null, @@ -1496,6 +1705,7 @@ export function routineService( ? db.select().from(agents).where(eq(agents.id, row.assigneeAgentId)).then((rows) => rows[0] ?? null) : null, row.parentIssueId ? issueSvc.getById(row.parentIssueId) : null, + getRoutineDescriptionDocument(row.id), db.select().from(routineTriggers).where(eq(routineTriggers.routineId, row.id)).orderBy(asc(routineTriggers.createdAt)), db .select({ @@ -1578,12 +1788,15 @@ export function routineService( project, assignee, parentIssue, + descriptionDocument, triggers: triggers as RoutineTrigger[], recentRuns, activeIssue, }; }, + getDescriptionDocument: async (routineId: string) => getRoutineDescriptionDocument(routineId), + create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise => { await assertProject(companyId, input.projectId ?? null); await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" }); diff --git a/ui/src/api/document-annotations.ts b/ui/src/api/document-annotations.ts index 81bad503e60..5a939c3a4c1 100644 --- a/ui/src/api/document-annotations.ts +++ b/ui/src/api/document-annotations.ts @@ -11,27 +11,50 @@ import { api } from "./client"; export type DocumentAnnotationListFilter = "open" | "resolved" | "all"; +export type DocumentAnnotationTarget = + | { kind: "issue"; issueId: string; documentKey: string } + | { kind: "routine"; routineId: string; documentKey: "description" }; + +function issueTarget(issueId: string, documentKey: string): DocumentAnnotationTarget { + return { kind: "issue", issueId, documentKey }; +} + +function targetBasePath(target: DocumentAnnotationTarget) { + if (target.kind === "routine") { + return `/routines/${target.routineId}/description/annotations`; + } + return `/issues/${target.issueId}/documents/${encodeURIComponent(target.documentKey)}/annotations`; +} + export const documentAnnotationsApi = { list: ( issueId: string, key: string, options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {}, + ) => documentAnnotationsApi.listForTarget(issueTarget(issueId, key), options), + listForTarget: ( + target: DocumentAnnotationTarget, + options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {}, ) => { const params = new URLSearchParams(); if (options.status) params.set("status", options.status); if (options.includeComments) params.set("includeComments", "true"); const qs = params.toString(); return api.get( - `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations${qs ? `?${qs}` : ""}`, + `${targetBasePath(target)}${qs ? `?${qs}` : ""}`, ); }, get: (issueId: string, key: string, threadId: string) => + documentAnnotationsApi.getForTarget(issueTarget(issueId, key), threadId), + getForTarget: (target: DocumentAnnotationTarget, threadId: string) => api.get( - `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`, + `${targetBasePath(target)}/${threadId}`, ), create: (issueId: string, key: string, data: CreateDocumentAnnotationThreadRequest) => + documentAnnotationsApi.createForTarget(issueTarget(issueId, key), data), + createForTarget: (target: DocumentAnnotationTarget, data: CreateDocumentAnnotationThreadRequest) => api.post( - `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations`, + targetBasePath(target), data, ), addComment: ( @@ -39,9 +62,14 @@ export const documentAnnotationsApi = { key: string, threadId: string, data: CreateDocumentAnnotationCommentRequest, + ) => documentAnnotationsApi.addCommentForTarget(issueTarget(issueId, key), threadId, data), + addCommentForTarget: ( + target: DocumentAnnotationTarget, + threadId: string, + data: CreateDocumentAnnotationCommentRequest, ) => api.post( - `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}/comments`, + `${targetBasePath(target)}/${threadId}/comments`, data, ), updateStatus: ( @@ -49,10 +77,15 @@ export const documentAnnotationsApi = { key: string, threadId: string, status: DocumentAnnotationThreadStatus, + ) => documentAnnotationsApi.updateStatusForTarget(issueTarget(issueId, key), threadId, status), + updateStatusForTarget: ( + target: DocumentAnnotationTarget, + threadId: string, + status: DocumentAnnotationThreadStatus, ) => { const payload: UpdateDocumentAnnotationThreadRequest = { status }; return api.patch( - `/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`, + `${targetBasePath(target)}/${threadId}`, payload, ); }, diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx index 107425cc721..4ffa4d4bef5 100644 --- a/ui/src/components/DocumentAnnotationPanel.tsx +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -23,7 +23,7 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { Textarea } from "@/components/ui/textarea"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { cn, relativeTime } from "@/lib/utils"; -import { documentAnnotationsApi } from "@/api/document-annotations"; +import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations"; import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; import { AgentIcon } from "./AgentIconPicker"; @@ -36,7 +36,8 @@ import type { CompanyUserProfile } from "@/lib/company-members"; export interface AnnotationPanelProps { open: boolean; onOpenChange: (open: boolean) => void; - issueId: string; + target?: DocumentAnnotationTarget; + issueId?: string; documentKey: string; documentRevisionNumber: number; baseRevisionId: string | null; @@ -106,6 +107,11 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { const [replyDrafts, setReplyDrafts] = useState>({}); const composerRef = useRef(null); const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined; + const annotationTarget = useMemo(() => { + if (props.target) return props.target; + if (!props.issueId) throw new Error("Document annotation panel requires an annotation target."); + return { kind: "issue", issueId: props.issueId, documentKey: props.documentKey }; + }, [props.documentKey, props.issueId, props.target]); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, @@ -137,26 +143,35 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { ); const annotationsQueryKey = useMemo( - () => queryKeys.issues.documentAnnotations(props.issueId, props.documentKey, "all"), - [props.documentKey, props.issueId], + () => annotationTarget.kind === "routine" + ? queryKeys.routines.documentAnnotations(annotationTarget.routineId, annotationTarget.documentKey, "all") + : queryKeys.issues.documentAnnotations(annotationTarget.issueId, annotationTarget.documentKey, "all"), + [annotationTarget], ); const invalidateAll = useCallback(() => { queryClient.invalidateQueries({ - predicate: (query) => - Array.isArray(query.queryKey) - && query.queryKey[0] === "issues" - && query.queryKey[1] === "document-annotations" - && query.queryKey[2] === props.issueId - && query.queryKey[3] === props.documentKey, + predicate: (query) => { + if (!Array.isArray(query.queryKey)) return false; + if (annotationTarget.kind === "routine") { + return query.queryKey[0] === "routines" + && query.queryKey[1] === "document-annotations" + && query.queryKey[2] === annotationTarget.routineId + && query.queryKey[3] === annotationTarget.documentKey; + } + return query.queryKey[0] === "issues" + && query.queryKey[1] === "document-annotations" + && query.queryKey[2] === annotationTarget.issueId + && query.queryKey[3] === annotationTarget.documentKey; + }, }); - }, [props.documentKey, props.issueId, queryClient]); + }, [annotationTarget, queryClient]); const createThread = useMutation({ mutationFn: async (body: string) => { if (!props.pendingAnchor) throw new Error("No selection to anchor to."); if (!props.baseRevisionId) throw new Error("Document has no revision yet."); - return documentAnnotationsApi.create(props.issueId, props.documentKey, { + return documentAnnotationsApi.createForTarget(annotationTarget, { baseRevisionId: props.baseRevisionId, baseRevisionNumber: props.baseRevisionNumber, selector: props.pendingAnchor.selector, @@ -172,8 +187,8 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { const optimisticThread = buildOptimisticThread({ body, selectedText: anchor.selectedText, - issueId: props.issueId, - documentKey: props.documentKey, + target: annotationTarget, + documentKey: annotationTarget.documentKey, baseRevisionId: props.baseRevisionId, baseRevisionNumber: props.baseRevisionNumber, normalizedStart: anchor.selector.position.normalizedStart, @@ -210,7 +225,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { const addReply = useMutation({ mutationFn: ({ threadId, body }: { threadId: string; body: string }) => - documentAnnotationsApi.addComment(props.issueId, props.documentKey, threadId, { body }), + documentAnnotationsApi.addCommentForTarget(annotationTarget, threadId, { body }), // Optimistically append the reply so it stays on screen through the round-trip. onMutate: async ({ threadId, body }) => { await queryClient.cancelQueries({ queryKey: annotationsQueryKey }); @@ -218,7 +233,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { const optimisticComment = buildOptimisticComment({ body, threadId, - issueId: props.issueId, + target: annotationTarget, author: currentUser, }); queryClient.setQueryData( @@ -242,7 +257,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { const updateStatus = useMutation({ mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) => - documentAnnotationsApi.updateStatus(props.issueId, props.documentKey, threadId, status), + documentAnnotationsApi.updateStatusForTarget(annotationTarget, threadId, status), onMutate: async ({ threadId, status }) => { await queryClient.cancelQueries({ queryKey: annotationsQueryKey }); const previous = queryClient.getQueryData(annotationsQueryKey); @@ -664,7 +679,7 @@ function optimisticId(prefix: string): string { function buildOptimisticComment(input: { body: string; threadId: string; - issueId: string; + target: DocumentAnnotationTarget; author: OptimisticAuthor; }): DocumentAnnotationComment { const now = new Date(); @@ -672,7 +687,8 @@ function buildOptimisticComment(input: { id: optimisticId("optimistic-comment"), companyId: "", threadId: input.threadId, - issueId: input.issueId, + issueId: input.target.kind === "issue" ? input.target.issueId : null, + routineId: input.target.kind === "routine" ? input.target.routineId : null, documentId: "", body: input.body, authorType: "user", @@ -688,7 +704,7 @@ function buildOptimisticComment(input: { function buildOptimisticThread(input: { body: string; selectedText: string; - issueId: string; + target: DocumentAnnotationTarget; documentKey: string; baseRevisionId: string; baseRevisionNumber: number; @@ -701,7 +717,7 @@ function buildOptimisticThread(input: { const comment = buildOptimisticComment({ body: input.body, threadId: id, - issueId: input.issueId, + target: input.target, author: input.author, }); // Only the fields the panel + overlay read need to be accurate; the optimistic @@ -709,7 +725,8 @@ function buildOptimisticThread(input: { // don't have to fabricate every backend-only column. return { id, - issueId: input.issueId, + issueId: input.target.kind === "issue" ? input.target.issueId : null, + routineId: input.target.kind === "routine" ? input.target.routineId : null, documentKey: input.documentKey, status: "open", anchorState: "active", diff --git a/ui/src/components/IssueDocumentAnnotations.tsx b/ui/src/components/IssueDocumentAnnotations.tsx index 399587f9948..20dd03451e5 100644 --- a/ui/src/components/IssueDocumentAnnotations.tsx +++ b/ui/src/components/IssueDocumentAnnotations.tsx @@ -4,7 +4,7 @@ import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from import { MessageSquare } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { documentAnnotationsApi } from "@/api/document-annotations"; +import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations"; import { queryKeys } from "@/lib/queryKeys"; import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash"; import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer"; @@ -16,9 +16,12 @@ const DESKTOP_ANNOTATION_PANEL_MIN_WIDTH = 280; const DESKTOP_ANNOTATION_PANEL_GAP = 24; const DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN = 16; +type AnnotationDocument = Pick; + export interface IssueDocumentAnnotationsProps { issueId: string; - doc: IssueDocument; + doc: AnnotationDocument; + target?: DocumentAnnotationTarget; /** The body that is being rendered/edited (current or historical revision). */ bodyMarkdown: string; /** True when a draft has unsaved changes or is currently saving. */ @@ -43,6 +46,7 @@ export interface IssueDocumentAnnotationsProps { export function IssueDocumentAnnotations({ issueId, doc, + target, bodyMarkdown, draftDirty, draftConflicted, @@ -152,8 +156,12 @@ export function IssueDocumentAnnotations({ }, [doc.key, isMobile, panelOpen]); const annotationsQuery = useQuery({ - queryKey: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"), - queryFn: () => documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }), + queryKey: target?.kind === "routine" + ? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all") + : queryKeys.issues.documentAnnotations(issueId, doc.key, "all"), + queryFn: () => target + ? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true }) + : documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }), staleTime: 30_000, }); const allThreads = annotationsQuery.data ?? []; @@ -284,6 +292,7 @@ export function IssueDocumentAnnotations({ } }} issueId={issueId} + target={target} documentKey={doc.key} documentRevisionNumber={doc.latestRevisionNumber} baseRevisionId={doc.latestRevisionId} @@ -359,6 +368,7 @@ export function IssueDocumentAnnotations({ export interface DocumentAnnotationsCountChipProps { issueId: string; docKey: string; + target?: DocumentAnnotationTarget; panelOpen: boolean; onToggle: () => void; } @@ -370,12 +380,17 @@ export interface DocumentAnnotationsCountChipProps { export function DocumentAnnotationsCountChip({ issueId, docKey, + target, panelOpen, onToggle, }: DocumentAnnotationsCountChipProps) { const annotationsQuery = useQuery({ - queryKey: queryKeys.issues.documentAnnotations(issueId, docKey, "all"), - queryFn: () => documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }), + queryKey: target?.kind === "routine" + ? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all") + : queryKeys.issues.documentAnnotations(issueId, docKey, "all"), + queryFn: () => target + ? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true }) + : documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }), staleTime: 30_000, }); const threads = annotationsQuery.data ?? []; diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx index 0ab24040612..8e3c76f668f 100644 --- a/ui/src/components/routine-sections/editable-sections.tsx +++ b/ui/src/components/routine-sections/editable-sections.tsx @@ -27,6 +27,7 @@ import { nextCronFires, previewFirePolicies } from "../../lib/cron-fires"; import { timeAgo } from "../../lib/timeAgo"; import { EmptyState } from "../EmptyState"; import { InlineEntitySelector } from "../InlineEntitySelector"; +import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations"; import { AgentIcon } from "../AgentIconPicker"; import { MarkdownEditor } from "../MarkdownEditor"; import { ScheduleEditor } from "../ScheduleEditor"; @@ -98,8 +99,11 @@ export function OverviewSection() { routineRuns, activity, saveRoutine, + saveConflict, + isSectionDirty, navigateToSection, } = ctx; + const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(false); const activeTriggers = routine.triggers.length; const nextFire = useMemo(() => { @@ -215,20 +219,63 @@ export function OverviewSection() { ) : null} {/* Instructions */} - setEditDraft((current) => ({ ...current, description }))} - placeholder="Add instructions..." - bordered={false} - contentClassName="min-h-[120px] text-[15px] leading-7" - mentions={mentionOptions} - onSubmit={() => { - if (!saveRoutine.isPending && editDraft.title.trim()) { - saveRoutine.mutate(); - } - }} - /> +
+
+ {routine.descriptionDocument ? ( + setDescriptionAnnotationsOpen((open) => !open)} + /> + ) : null} +
+ {routine.descriptionDocument ? ( + + setEditDraft((current) => ({ ...current, description }))} + placeholder="Add instructions..." + bordered={false} + contentClassName="min-h-[120px] text-[15px] leading-7" + mentions={mentionOptions} + onSubmit={() => { + if (!saveRoutine.isPending && editDraft.title.trim()) { + saveRoutine.mutate(); + } + }} + /> + + ) : ( + setEditDraft((current) => ({ ...current, description }))} + placeholder="Add instructions..." + bordered={false} + contentClassName="min-h-[120px] text-[15px] leading-7" + mentions={mentionOptions} + onSubmit={() => { + if (!saveRoutine.isPending && editDraft.title.trim()) { + saveRoutine.mutate(); + } + }} + /> + )} +
{/* Variables peek */}
diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 292927b21f7..f696b0b93d5 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -236,6 +236,41 @@ describe("LiveUpdatesProvider issue invalidation", () => { }); }); + it("refreshes routine description annotation caches when routine annotation activity arrives", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: () => undefined, + }; + + __liveUpdatesTestUtils.invalidateActivityQueries( + queryClient as never, + "company-1", + { + entityType: "routine", + entityId: "routine-1", + action: "routine.document_annotation_comment_added", + actorType: "user", + actorId: "user-2", + details: { + documentKey: "description", + threadId: "thread-1", + commentId: "comment-1", + }, + }, + { userId: "user-1", agentId: null }, + ); + + expect(invalidations).toContainEqual({ + queryKey: ["routines"], + }); + expect(invalidations).toContainEqual({ + queryKey: ["routines", "document-annotations", "routine-1", "description"], + }); + }); + it("keeps self-authored comment events from refetching the active issue tree", () => { const invalidations: unknown[] = []; const queryClient = { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 8544429a6a5..1e1b908360b 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -422,6 +422,13 @@ const ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([ "issue.document_annotation_thread_reopened", "issue.document_annotation_remapped", ]); +const ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([ + "routine.document_annotation_thread_created", + "routine.document_annotation_comment_added", + "routine.document_annotation_thread_resolved", + "routine.document_annotation_thread_reopened", + "routine.document_annotation_remapped", +]); const AGENT_TOAST_STATUSES = new Set(["error"]); const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]); @@ -780,6 +787,12 @@ function invalidateActivityQueries( if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") { queryClient.invalidateQueries({ queryKey: ["routines"] }); + if (entityType === "routine" && action && ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action) && entityId) { + const documentKey = readString(details?.key) ?? readString(details?.documentKey) ?? "description"; + queryClient.invalidateQueries({ + queryKey: ["routines", "document-annotations", entityId, documentKey], + }); + } return; } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 1749ce9c9c3..9e653d2c5a0 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -126,6 +126,8 @@ export const queryKeys = { runs: (id: string) => ["routines", "runs", id] as const, revisions: (id: string) => ["routines", "revisions", id] as const, activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const, + documentAnnotations: (routineId: string, key: "description", status: "open" | "resolved" | "all" = "all") => + ["routines", "document-annotations", routineId, key, status] as const, }, executionWorkspaces: { list: (companyId: string, filters?: Record) =>