diff --git a/server/src/__tests__/document-annotation-routes.test.ts b/server/src/__tests__/document-annotation-routes.test.ts new file mode 100644 index 00000000000..c23f1f973aa --- /dev/null +++ b/server/src/__tests__/document-annotation-routes.test.ts @@ -0,0 +1,267 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const issueId = "11111111-1111-4111-8111-111111111111"; +const companyId = "22222222-2222-4222-8222-222222222222"; +const otherCompanyId = "33333333-3333-4333-8333-333333333333"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + assertCheckoutOwner: vi.fn(), +})); +const mockDocumentService = vi.hoisted(() => ({ + getIssueDocumentByKey: vi.fn(), +})); +const mockAnnotationService = vi.hoisted(() => ({ + listThreadsForIssueDocument: vi.fn(), + getThreadForIssueDocument: vi.fn(), + createThread: vi.fn(), + addComment: vi.fn(), + updateThread: vi.fn(), + remapOpenThreadsForDocument: vi.fn(), +})); +const mockIssueReferenceService = vi.hoisted(() => ({ + diffIssueReferenceSummary: vi.fn(() => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + })), + emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })), + listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })), + syncAnnotationComment: vi.fn(async () => undefined), + syncComment: vi.fn(async () => undefined), + syncDocument: vi.fn(async () => undefined), + syncIssue: vi.fn(async () => undefined), +})); +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), +})); +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +const documentPayload = { + id: "document-1", + companyId, + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "Alpha selected text omega", + latestRevisionId: "44444444-4444-4444-8444-444444444444", + latestRevisionNumber: 1, + createdByAgentId: null, + createdByUserId: "board-user", + updatedByAgentId: null, + updatedByUserId: "board-user", + createdAt: new Date("2026-05-14T12:00:00.000Z"), + updatedAt: new Date("2026-05-14T12:00:00.000Z"), +}; + +const annotationThread = { + id: "55555555-5555-4555-8555-555555555555", + companyId, + issueId, + documentId: "document-1", + documentKey: "plan", + status: "open", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: documentPayload.latestRevisionId, + originalRevisionNumber: 1, + currentRevisionId: documentPayload.latestRevisionId, + 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-05-14T12:01:00.000Z"), + updatedAt: new Date("2026-05-14T12:01:00.000Z"), +}; + +const annotationComment = { + id: "66666666-6666-4666-8666-666666666666", + companyId, + threadId: annotationThread.id, + issueId, + documentId: "document-1", + body: "Please review PAP-1", + authorType: "user", + authorAgentId: null, + authorUserId: "board-user", + createdByRunId: null, + createdAt: new Date("2026-05-14T12:01:00.000Z"), + updatedAt: new Date("2026-05-14T12:01:00.000Z"), +}; + +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ canUser: vi.fn(), hasPermission: vi.fn(async () => false) }), + agentService: () => ({ getById: vi.fn(), list: vi.fn(async () => []) }), + companyService: () => ({ getById: vi.fn(async () => ({ id: companyId, attachmentMaxBytes: 10_000_000 })) }), + documentAnnotationService: () => mockAnnotationService, + documentService: () => mockDocumentService, + environmentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({}), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ id: "settings", general: {} })), + getExperimental: vi.fn(async () => ({})), + getGeneral: vi.fn(async () => ({})), + listCompanyIds: vi.fn(async () => [companyId]), + }), + issueApprovalService: () => ({}), + issueRecoveryActionService: () => ({ + getActiveForIssue: vi.fn(async () => null), + listActiveForIssues: vi.fn(async () => new Map()), + }), + issueReferenceService: () => mockIssueReferenceService, + issueService: () => mockIssueService, + issueThreadInteractionService: () => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }), + workProductService: () => ({}), + })); +} + +async function createApp(actor: "board" | "agent" = "board", actorCompanyId = companyId) { + const [{ issueRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/issues.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: "77777777-7777-4777-8777-777777777777", + companyId: actorCompanyId, + runId: "88888888-8888-4888-8888-888888888888", + } + : { + type: "board", + userId: "board-user", + companyIds: [actorCompanyId], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +describe("document annotation routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.clearAllMocks(); + mockIssueService.getById.mockResolvedValue({ + id: issueId, + companyId, + title: "Annotation API", + status: "in_progress", + assigneeAgentId: null, + }); + mockIssueService.assertCheckoutOwner.mockResolvedValue({}); + mockDocumentService.getIssueDocumentByKey.mockResolvedValue(documentPayload); + mockAnnotationService.listThreadsForIssueDocument.mockResolvedValue([{ ...annotationThread, comments: [annotationComment] }]); + mockAnnotationService.getThreadForIssueDocument.mockResolvedValue({ ...annotationThread, comments: [annotationComment] }); + mockAnnotationService.createThread.mockResolvedValue({ ...annotationThread, comments: [annotationComment] }); + mockAnnotationService.addComment.mockResolvedValue(annotationComment); + mockAnnotationService.updateThread.mockResolvedValue({ ...annotationThread, status: "resolved" }); + mockAnnotationService.remapOpenThreadsForDocument.mockResolvedValue([]); + }); + + it("includes compact open annotations by default for agent document reads", async () => { + const res = await request(await createApp("agent")) + .get(`/api/issues/${issueId}/documents/plan`) + .expect(200); + + expect(res.body.annotations).toHaveLength(1); + expect(mockAnnotationService.listThreadsForIssueDocument).toHaveBeenCalledWith(issueId, "plan", { + status: "open", + includeComments: true, + }); + }); + + it("creates annotation threads, syncs references, logs activity, and wakes the assignee", async () => { + mockIssueService.getById.mockResolvedValue({ + id: issueId, + companyId, + title: "Annotation API", + status: "todo", + assigneeAgentId: "99999999-9999-4999-8999-999999999999", + }); + + const res = await request(await createApp()) + .post(`/api/issues/${issueId}/documents/plan/annotations`) + .send({ + baseRevisionId: documentPayload.latestRevisionId, + baseRevisionNumber: 1, + selector: annotationThread.anchorSelector, + body: "Please review PAP-1", + }) + .expect(201); + + expect(res.body.id).toBe(annotationThread.id); + expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "issue.document_annotation_thread_created", + })); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "99999999-9999-4999-8999-999999999999", + expect.objectContaining({ + payload: expect.objectContaining({ + annotationThreadId: annotationThread.id, + annotationCommentId: annotationComment.id, + }), + }), + ); + }); + + it("rejects agent cross-company annotation reads", async () => { + await request(await createApp("agent", otherCompanyId)) + .get(`/api/issues/${issueId}/documents/plan/annotations`) + .expect(403); + }); + + it("adds annotation comments and resolves threads", async () => { + await request(await createApp()) + .post(`/api/issues/${issueId}/documents/plan/annotations/${annotationThread.id}/comments`) + .send({ body: "Reply with PAP-2" }) + .expect(201); + expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id); + + const resolved = await request(await createApp()) + .patch(`/api/issues/${issueId}/documents/plan/annotations/${annotationThread.id}`) + .send({ status: "resolved" }) + .expect(200); + expect(resolved.body.status).toBe("resolved"); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "issue.document_annotation_thread_resolved", + })); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 3e2549b373e..fb7376a91da 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -23,6 +23,8 @@ import { createIssueWorkProductSchema, createIssueLabelSchema, checkoutIssueSchema, + createDocumentAnnotationCommentSchema, + createDocumentAnnotationThreadSchema, createChildIssueSchema, createIssueSchema, resolveCreateIssueStatusDefault, @@ -38,6 +40,7 @@ import { restoreIssueDocumentRevisionSchema, respondIssueThreadInteractionSchema, updateIssueWorkProductSchema, + updateDocumentAnnotationThreadSchema, upsertIssueDocumentSchema, updateIssueSchema, getClosedIsolatedExecutionWorkspaceMessage, @@ -71,6 +74,7 @@ import { issueService, clampIssueListLimit, documentService, + documentAnnotationService, logActivity, projectService, routineService, @@ -868,6 +872,7 @@ export function issueRoutes( const executionWorkspacesSvc = executionWorkspaceServiceDirect(db); const workProductsSvc = workProductService(db); const documentsSvc = documentService(db); + const documentAnnotationsSvc = documentAnnotationService(db); const issueReferencesSvc = issueReferenceService(db); const issueThreadInteractionsSvc = issueThreadInteractionService(db); const routinesSvc = routineService(db, { @@ -1106,6 +1111,65 @@ export function issueRoutes( return value === true || value === "true" || value === "1"; } + function shouldIncludeDocumentAnnotations(req: Request) { + if (req.query.includeAnnotations === "false" || req.query.includeAnnotations === "0") return false; + return req.actor.type === "agent" || parseBooleanQuery(req.query.includeAnnotations); + } + + 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, + }, + }; + } + + function queueAnnotationCommentWakeup(input: { + issue: { id: string; assigneeAgentId: string | null; status: string }; + actor: { actorType: "user" | "agent"; actorId: string }; + threadId: string; + commentId: string; + documentKey: string; + }) { + const assigneeId = input.issue.assigneeAgentId; + const selfComment = input.actor.actorType === "agent" && input.actor.actorId === assigneeId; + if (!assigneeId || selfComment || isClosedIssueStatus(input.issue.status)) return; + void heartbeat.wakeup(assigneeId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { + issueId: input.issue.id, + annotationThreadId: input.threadId, + annotationCommentId: input.commentId, + documentKey: input.documentKey, + mutation: "document_annotation_comment", + }, + requestedByActorType: input.actor.actorType, + requestedByActorId: input.actor.actorId, + contextSnapshot: { + issueId: input.issue.id, + taskId: input.issue.id, + annotationThreadId: input.threadId, + annotationCommentId: input.commentId, + documentKey: input.documentKey, + source: "issue.document.annotation", + wakeReason: "issue_commented", + }, + }).catch((err) => logger.warn({ + err, + issueId: input.issue.id, + annotationThreadId: input.threadId, + annotationCommentId: input.commentId, + }, "failed to wake assignee on document annotation comment")); + } + async function assertIssueEnvironmentSelection( companyId: string, environmentId: string | null | undefined, @@ -2448,9 +2512,239 @@ export function issueRoutes( res.status(404).json({ error: "Document not found" }); return; } - res.json(doc); + if (!shouldIncludeDocumentAnnotations(req)) { + res.json(doc); + return; + } + const annotations = await documentAnnotationsSvc.listThreadsForIssueDocument(issue.id, keyParsed.data, { + status: "open", + includeComments: true, + }); + res.json({ ...doc, annotations }); }); + router.get("/issues/:id/documents/:key/annotations", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const status = req.query.status === "resolved" || req.query.status === "all" ? req.query.status : "open"; + const threads = await documentAnnotationsSvc.listThreadsForIssueDocument(issue.id, keyParsed.data, { + status, + includeComments: parseBooleanQuery(req.query.includeComments), + }); + res.json(threads); + }); + + router.post( + "/issues/:id/documents/:key/annotations", + validate(createDocumentAnnotationThreadSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + + const { actor, annotationActor } = annotationActorInput(req); + const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const thread = await documentAnnotationsSvc.createThread(issue.id, keyParsed.data, req.body, annotationActor); + const firstComment = thread.comments[0]; + if (firstComment) await issueReferencesSvc.syncAnnotationComment(firstComment.id); + const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_annotation_thread_created", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: thread.documentKey, + documentId: thread.documentId, + threadId: thread.id, + commentId: firstComment?.id ?? null, + revisionNumber: thread.currentRevisionNumber, + quote: thread.selectedText.slice(0, 240), + ...summarizeIssueReferenceActivityDetails({ + addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), + removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), + currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), + }), + }, + }); + + if (firstComment) { + queueAnnotationCommentWakeup({ + issue, + actor, + threadId: thread.id, + commentId: firstComment.id, + documentKey: thread.documentKey, + }); + } + + res.status(201).json(thread); + }, + ); + + router.get("/issues/:id/documents/:key/annotations/:threadId", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const thread = await documentAnnotationsSvc.getThreadForIssueDocument( + issue.id, + keyParsed.data, + req.params.threadId as string, + ); + if (!thread) { + res.status(404).json({ error: "Annotation thread not found" }); + return; + } + res.json(thread); + }); + + router.post( + "/issues/:id/documents/:key/annotations/:threadId/comments", + validate(createDocumentAnnotationCommentSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + + const { actor, annotationActor } = annotationActorInput(req); + const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const comment = await documentAnnotationsSvc.addComment( + issue.id, + keyParsed.data, + req.params.threadId as string, + req.body, + annotationActor, + ); + await issueReferencesSvc.syncAnnotationComment(comment.id); + const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_annotation_comment_added", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: keyParsed.data, + threadId: comment.threadId, + commentId: comment.id, + bodySnippet: comment.body.slice(0, 120), + ...summarizeIssueReferenceActivityDetails({ + addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), + removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), + currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity), + }), + }, + }); + + queueAnnotationCommentWakeup({ + issue, + actor, + threadId: comment.threadId, + commentId: comment.id, + documentKey: keyParsed.data, + }); + + res.status(201).json(comment); + }, + ); + + router.patch( + "/issues/:id/documents/:key/annotations/:threadId", + validate(updateDocumentAnnotationThreadSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const thread = await documentAnnotationsSvc.updateThread( + issue.id, + keyParsed.data, + req.params.threadId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: thread.status === "resolved" + ? "issue.document_annotation_thread_resolved" + : "issue.document_annotation_thread_reopened", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: thread.documentKey, + documentId: thread.documentId, + threadId: thread.id, + status: thread.status, + }, + }); + res.json(thread); + }, + ); + router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -2488,6 +2782,16 @@ export function issueRoutes( await issueReferencesSvc.syncDocument(doc.id); const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); + const remappedAnnotations = result.created + ? [] + : await documentAnnotationsSvc.remapOpenThreadsForDocument({ + issueId: issue.id, + key: doc.key, + documentId: doc.id, + nextRevisionId: doc.latestRevisionId, + nextRevisionNumber: doc.latestRevisionNumber, + nextBody: doc.body, + }); await logActivity(db, { companyId: issue.companyId, @@ -2513,6 +2817,28 @@ export function issueRoutes( }, }); + for (const remap of remappedAnnotations) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_annotation_remapped", + entityType: "issue", + entityId: issue.id, + details: { + key: doc.key, + documentId: doc.id, + threadId: remap.thread.id, + revisionNumber: doc.latestRevisionNumber, + anchorState: remap.thread.anchorState, + anchorConfidence: remap.thread.anchorConfidence, + snapshotId: remap.snapshot.id, + }, + }); + } + if (!result.created) { const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( issue, @@ -2684,6 +3010,14 @@ export function issueRoutes( await issueReferencesSvc.syncDocument(result.document.id); const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); + const remappedAnnotations = await documentAnnotationsSvc.remapOpenThreadsForDocument({ + issueId: issue.id, + key: result.document.key, + documentId: result.document.id, + nextRevisionId: result.document.latestRevisionId, + nextRevisionNumber: result.document.latestRevisionNumber, + nextBody: result.document.body, + }); await logActivity(db, { companyId: issue.companyId, @@ -2710,6 +3044,28 @@ export function issueRoutes( }, }); + for (const remap of remappedAnnotations) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_annotation_remapped", + entityType: "issue", + entityId: issue.id, + details: { + key: result.document.key, + documentId: result.document.id, + threadId: remap.thread.id, + revisionNumber: result.document.latestRevisionNumber, + anchorState: remap.thread.anchorState, + anchorConfidence: remap.thread.anchorConfidence, + snapshotId: remap.snapshot.id, + }, + }); + } + const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( issue, { diff --git a/server/src/services/document-annotations.ts b/server/src/services/document-annotations.ts new file mode 100644 index 00000000000..4ee69c95852 --- /dev/null +++ b/server/src/services/document-annotations.ts @@ -0,0 +1,430 @@ +import { and, asc, desc, eq, inArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + documentAnnotationAnchorSnapshots, + documentAnnotationComments, + documentAnnotationThreads, + documents, + issueDocuments, +} from "@paperclipai/db"; +import { + anchorSnapshotToSelector, + remapDocumentAnchor, + selectorToAnchorSnapshot, + verifyDocumentAnchorSelector, + type DocumentAnnotationAnchorSnapshot, + type DocumentAnnotationComment, + type DocumentAnnotationThread, + CreateDocumentAnnotationComment, + CreateDocumentAnnotationThread, + UpdateDocumentAnnotationThread, +} from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; + +type ActorInput = { + actorType: "agent" | "user"; + actorId: string; + agentId?: string | null; + userId?: string | null; + runId?: string | null; +}; + +type IssueDocumentRow = { + issueId: 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, + documentId: documentAnnotationThreads.documentId, + documentKey: documentAnnotationThreads.documentKey, + status: documentAnnotationThreads.status, + anchorState: documentAnnotationThreads.anchorState, + anchorConfidence: documentAnnotationThreads.anchorConfidence, + originalRevisionId: documentAnnotationThreads.originalRevisionId, + originalRevisionNumber: documentAnnotationThreads.originalRevisionNumber, + currentRevisionId: documentAnnotationThreads.currentRevisionId, + currentRevisionNumber: documentAnnotationThreads.currentRevisionNumber, + selectedText: documentAnnotationThreads.selectedText, + prefixText: documentAnnotationThreads.prefixText, + suffixText: documentAnnotationThreads.suffixText, + normalizedStart: documentAnnotationThreads.normalizedStart, + normalizedEnd: documentAnnotationThreads.normalizedEnd, + markdownStart: documentAnnotationThreads.markdownStart, + markdownEnd: documentAnnotationThreads.markdownEnd, + anchorSelector: documentAnnotationThreads.anchorSelector, + createdByAgentId: documentAnnotationThreads.createdByAgentId, + createdByUserId: documentAnnotationThreads.createdByUserId, + resolvedByAgentId: documentAnnotationThreads.resolvedByAgentId, + resolvedByUserId: documentAnnotationThreads.resolvedByUserId, + resolvedAt: documentAnnotationThreads.resolvedAt, + createdAt: documentAnnotationThreads.createdAt, + updatedAt: documentAnnotationThreads.updatedAt, +}; + +const commentSelect = { + id: documentAnnotationComments.id, + companyId: documentAnnotationComments.companyId, + threadId: documentAnnotationComments.threadId, + issueId: documentAnnotationComments.issueId, + documentId: documentAnnotationComments.documentId, + body: documentAnnotationComments.body, + authorType: documentAnnotationComments.authorType, + authorAgentId: documentAnnotationComments.authorAgentId, + authorUserId: documentAnnotationComments.authorUserId, + createdByRunId: documentAnnotationComments.createdByRunId, + createdAt: documentAnnotationComments.createdAt, + updatedAt: documentAnnotationComments.updatedAt, +}; + +function snapshotFromThread(thread: Pick): DocumentAnnotationAnchorSnapshot { + return { + selectedText: thread.selectedText, + prefixText: thread.prefixText, + suffixText: thread.suffixText, + normalizedStart: thread.normalizedStart, + normalizedEnd: thread.normalizedEnd, + markdownStart: thread.markdownStart, + markdownEnd: thread.markdownEnd, + }; +} + +export function documentAnnotationService(db: Db) { + async function getIssueDocument(issueId: string, key: string, dbOrTx: any = db): Promise { + return dbOrTx + .select({ + issueId: issueDocuments.issueId, + companyId: documents.companyId, + documentId: documents.id, + documentKey: issueDocuments.key, + latestBody: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))) + .then((rows: IssueDocumentRow[]) => rows[0] ?? null); + } + + async function getThreadForIssue( + issueId: string, + documentKey: string, + threadId: string, + dbOrTx: any = db, + ): Promise { + return dbOrTx + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and( + eq(documentAnnotationThreads.id, threadId), + eq(documentAnnotationThreads.issueId, issueId), + 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 + .select(commentSelect) + .from(documentAnnotationComments) + .where(inArray(documentAnnotationComments.threadId, threadIds)) + .orderBy(asc(documentAnnotationComments.createdAt), asc(documentAnnotationComments.id)); + } + + return { + listThreadsForIssueDocument: async ( + issueId: string, + key: string, + options: { status?: "open" | "resolved" | "all"; includeComments?: boolean } = {}, + ) => { + const doc = await getIssueDocument(issueId, key); + if (!doc) throw notFound("Document not found"); + const conditions = [ + eq(documentAnnotationThreads.issueId, issueId), + 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; + const comments = await commentsForThreads([thread.id]); + return { ...thread, comments }; + }, + + createThread: async ( + issueId: string, + key: string, + input: CreateDocumentAnnotationThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const doc = await getIssueDocument(issueId, 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, + 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, + documentId: doc.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(commentSelect); + + return { ...thread, comments: [comment] }; + }), + + addComment: async ( + issueId: string, + key: string, + threadId: string, + input: CreateDocumentAnnotationComment, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const thread = await getThreadForIssue(issueId, 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: thread.issueId, + documentId: thread.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(commentSelect); + await tx + .update(documentAnnotationThreads) + .set({ updatedAt: now }) + .where(eq(documentAnnotationThreads.id, thread.id)); + return comment; + }), + + updateThread: async ( + issueId: string, + key: string, + threadId: string, + input: UpdateDocumentAnnotationThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const thread = await getThreadForIssue(issueId, 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; + }), + + listOpenContextForIssue: async (issueId: string, limit = 20) => { + const threads: DocumentAnnotationThread[] = await db + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and(eq(documentAnnotationThreads.issueId, issueId), eq(documentAnnotationThreads.status, "open"))) + .orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id)) + .limit(limit); + const comments = await commentsForThreads(threads.map((thread) => thread.id)); + const latestCommentByThread = new Map(); + for (const comment of comments) latestCommentByThread.set(comment.threadId, comment); + return threads.map((thread) => ({ + id: thread.id, + documentKey: thread.documentKey, + status: thread.status, + anchorState: thread.anchorState, + anchorConfidence: thread.anchorConfidence, + currentRevisionNumber: thread.currentRevisionNumber, + selectedText: thread.selectedText, + prefixText: thread.prefixText, + suffixText: thread.suffixText, + latestComment: latestCommentByThread.get(thread.id) ?? null, + })); + }, + + remapOpenThreadsForDocument: async (input: { + issueId: 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.issueId, input.issueId), + 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/heartbeat.ts b/server/src/services/heartbeat.ts index 8c34e99233a..0412632f8b8 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -29,6 +29,8 @@ import { activityLog, approvals, companySkills as companySkillsTable, + documentAnnotationComments, + documentAnnotationThreads, documentRevisions, issueDocuments, heartbeatRunEvents, @@ -1964,6 +1966,7 @@ async function buildPaperclipWakePayload(input: { }) { const executionStage = parseObject(input.contextSnapshot.executionStage); const commentIds = extractWakeCommentIds(input.contextSnapshot); + const annotationCommentId = readNonEmptyString(input.contextSnapshot.annotationCommentId); const issueId = readNonEmptyString(input.contextSnapshot.issueId); const continuationSummary = input.continuationSummary ?? null; const issueSummary = @@ -2054,6 +2057,57 @@ async function buildPaperclipWakePayload(input: { }); } + const annotationDeltas = annotationCommentId + ? await input.db + .select({ + id: documentAnnotationComments.id, + issueId: documentAnnotationComments.issueId, + threadId: documentAnnotationComments.threadId, + body: documentAnnotationComments.body, + authorType: documentAnnotationComments.authorType, + authorAgentId: documentAnnotationComments.authorAgentId, + authorUserId: documentAnnotationComments.authorUserId, + createdAt: documentAnnotationComments.createdAt, + documentKey: documentAnnotationThreads.documentKey, + status: documentAnnotationThreads.status, + anchorState: documentAnnotationThreads.anchorState, + anchorConfidence: documentAnnotationThreads.anchorConfidence, + currentRevisionNumber: documentAnnotationThreads.currentRevisionNumber, + selectedText: documentAnnotationThreads.selectedText, + prefixText: documentAnnotationThreads.prefixText, + suffixText: documentAnnotationThreads.suffixText, + }) + .from(documentAnnotationComments) + .innerJoin(documentAnnotationThreads, eq(documentAnnotationComments.threadId, documentAnnotationThreads.id)) + .where(and( + eq(documentAnnotationComments.companyId, input.companyId), + eq(documentAnnotationComments.id, annotationCommentId), + )) + .then((rows) => rows.map((row) => ({ + id: row.id, + issueId: row.issueId, + threadId: row.threadId, + documentKey: row.documentKey, + revisionNumber: row.currentRevisionNumber, + quote: row.selectedText, + prefix: row.prefixText, + suffix: row.suffixText, + threadStatus: row.status, + anchorState: row.anchorState, + anchorConfidence: row.anchorConfidence, + body: row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS + ? row.body.slice(0, MAX_INLINE_WAKE_COMMENT_BODY_CHARS) + : row.body, + bodyTruncated: row.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS, + createdAt: row.createdAt.toISOString(), + author: row.authorAgentId + ? { type: "agent", id: row.authorAgentId } + : row.authorUserId + ? { type: "user", id: row.authorUserId } + : { type: row.authorType, id: null }, + }))) + : []; + return { reason: readNonEmptyString(input.contextSnapshot.wakeReason), issue: issueSummary @@ -2111,6 +2165,7 @@ async function buildPaperclipWakePayload(input: { commentIds, latestCommentId: commentIds[commentIds.length - 1] ?? null, comments, + annotationDeltas, commentWindow: { requestedCount: commentIds.length, includedCount: comments.length, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index fc5b5ab5d3f..803d2618080 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -6,6 +6,7 @@ export { agentService, deduplicateAgentName } from "./agents.js"; export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js"; export { assetService } from "./assets.js"; export { documentService, extractLegacyPlanBody } from "./documents.js"; +export { documentAnnotationService } from "./document-annotations.js"; export { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, buildContinuationSummaryMarkdown, diff --git a/server/src/services/issue-references.ts b/server/src/services/issue-references.ts index 505461bef7b..89c18c12a39 100644 --- a/server/src/services/issue-references.ts +++ b/server/src/services/issue-references.ts @@ -1,6 +1,13 @@ import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { documents, issueComments, issueDocuments, issueReferenceMentions, issues } from "@paperclipai/db"; +import { + documentAnnotationComments, + documents, + issueComments, + issueDocuments, + issueReferenceMentions, + issues, +} from "@paperclipai/db"; import type { IssueReferenceSource, IssueReferenceSourceKind, @@ -230,6 +237,29 @@ export function issueReferenceService(db: Db) { }, dbOrTx); } + async function syncAnnotationComment(commentId: string, dbOrTx: any = db) { + const comment = await dbOrTx + .select({ + id: documentAnnotationComments.id, + companyId: documentAnnotationComments.companyId, + issueId: documentAnnotationComments.issueId, + body: documentAnnotationComments.body, + }) + .from(documentAnnotationComments) + .where(eq(documentAnnotationComments.id, commentId)) + .then((rows: Array<{ id: string; companyId: string; issueId: string; body: string }>) => rows[0] ?? null); + if (!comment) throw notFound("Document annotation comment not found"); + + await replaceSourceMentions({ + companyId: comment.companyId, + sourceIssueId: comment.issueId, + sourceKind: "comment", + sourceRecordId: comment.id, + documentKey: null, + text: comment.body, + }, dbOrTx); + } + async function syncDocument(documentId: string, dbOrTx: any = db) { const document = await dbOrTx .select({ @@ -396,6 +426,7 @@ export function issueReferenceService(db: Db) { return { syncIssue, syncComment, + syncAnnotationComment, syncDocument, deleteDocumentSource, syncAllForIssue,