feat(routines): support routine description annotations

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 13:33:23 +00:00
1 parent 859ec5bd4f
commit fa764504ed
8 files changed
+1174 -17

No files matched your search

@@ -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 $$;
@@ -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,
),
}),
);
+19
View File
@@ -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;
@@ -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<typeof import("../routes/routines.js")>("../routes/routines.js"),
vi.importActual<typeof import("../middleware/index.js")>("../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);
});
});
@@ -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",
+212 -1
View File
@@ -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);
});
+215 -2
View File
@@ -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<RoutineDescriptionDocument | null> {
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<RoutineDescriptionDocument> {
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<RoutineDetail | null> => {
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<Routine> => {
await assertProject(companyId, input.projectId ?? null);
await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" });
@@ -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 */}
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => 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();
}
}}
/>
<div className="space-y-2">
<div className="flex items-center justify-end">
{routine.descriptionDocument ? (
<DocumentAnnotationsCountChip
issueId={routine.id}
docKey="description"
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
panelOpen={descriptionAnnotationsOpen}
onToggle={() => setDescriptionAnnotationsOpen((open) => !open)}
/>
) : null}
</div>
{routine.descriptionDocument ? (
<IssueDocumentAnnotations
issueId={routine.id}
doc={routine.descriptionDocument}
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
bodyMarkdown={editDraft.description}
draftDirty={isSectionDirty("overview") || saveRoutine.isPending}
draftConflicted={saveConflict}
historicalPreview={false}
locationHash={typeof window === "undefined" ? "" : window.location.hash}
panelOpen={descriptionAnnotationsOpen}
onPanelOpenChange={setDescriptionAnnotationsOpen}
>
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => 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();
}
}}
/>
</IssueDocumentAnnotations>
) : (
<MarkdownEditor
ref={descriptionEditorRef}
value={editDraft.description}
onChange={(description) => 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();
}
}}
/>
)}
</div>
{/* Variables peek */}
<div className="space-y-3">