diff --git a/packages/db/src/migrations/0107_routine_description_annotations.sql b/packages/db/src/migrations/0107_routine_description_annotations.sql new file mode 100644 index 00000000000..9c34612b618 --- /dev/null +++ b/packages/db/src/migrations/0107_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/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/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/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/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx index 8fb50579a1e..885d6abc8fe 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, getScheduleCronValidation } 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 */}