mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Support routine description annotations
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5fe8320fac
commit
52939c90c4
22 files changed
+1676
-55
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 $$;
|
||||
@@ -722,6 +722,13 @@
|
||||
"when": 1781490000001,
|
||||
"tag": "0102_workspace_operations_issue_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 103,
|
||||
"version": "7",
|
||||
"when": 1781490000002,
|
||||
"tag": "0103_routine_description_annotations",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { documents } from "./documents.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issueComments } from "./issue_comments.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { routines } from "./routines.js";
|
||||
|
||||
export const documentAnnotationComments = pgTable(
|
||||
"document_annotation_comments",
|
||||
@@ -14,7 +15,8 @@ export const documentAnnotationComments = pgTable(
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }),
|
||||
routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
body: text("body").notNull(),
|
||||
authorType: text("author_type").$type<IssueCommentAuthorType>().notNull(),
|
||||
@@ -36,6 +38,11 @@ export const documentAnnotationComments = pgTable(
|
||||
table.issueId,
|
||||
table.createdAt,
|
||||
),
|
||||
companyRoutineCreatedAtIdx: index("document_annotation_comments_company_routine_created_at_idx").on(
|
||||
table.companyId,
|
||||
table.routineId,
|
||||
table.createdAt,
|
||||
),
|
||||
companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on(
|
||||
table.companyId,
|
||||
table.documentId,
|
||||
|
||||
@@ -10,13 +10,15 @@ import { companies } from "./companies.js";
|
||||
import { documentRevisions } from "./document_revisions.js";
|
||||
import { documents } from "./documents.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { routines } from "./routines.js";
|
||||
|
||||
export const documentAnnotationThreads = pgTable(
|
||||
"document_annotation_threads",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }),
|
||||
routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
documentKey: text("document_key").notNull(),
|
||||
status: text("status").$type<DocumentAnnotationThreadStatus>().notNull().default("open"),
|
||||
@@ -56,6 +58,11 @@ export const documentAnnotationThreads = pgTable(
|
||||
table.issueId,
|
||||
table.status,
|
||||
),
|
||||
companyRoutineStatusIdx: index("document_annotation_threads_company_routine_status_idx").on(
|
||||
table.companyId,
|
||||
table.routineId,
|
||||
table.status,
|
||||
),
|
||||
companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on(
|
||||
table.companyId,
|
||||
table.documentId,
|
||||
|
||||
@@ -56,6 +56,7 @@ export { issueAttachments } from "./issue_attachments.js";
|
||||
export { documents } from "./documents.js";
|
||||
export { documentRevisions } from "./document_revisions.js";
|
||||
export { issueDocuments } from "./issue_documents.js";
|
||||
export { routineDocuments } from "./routine_documents.js";
|
||||
export { documentAnnotationThreads } from "./document_annotation_threads.js";
|
||||
export { documentAnnotationComments } from "./document_annotation_comments.js";
|
||||
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -741,6 +741,7 @@ export type {
|
||||
Routine,
|
||||
RoutineEnvConfig,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineDescriptionDocument,
|
||||
RoutineVariable,
|
||||
RoutineVariableDefaultValue,
|
||||
RoutineRevisionSnapshotRoutineV1,
|
||||
|
||||
@@ -55,7 +55,8 @@ export interface DocumentAnnotationAnchorSnapshot {
|
||||
export interface DocumentAnnotationThread {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
issueId: string | null;
|
||||
routineId?: string | null;
|
||||
documentId: string;
|
||||
documentKey: string;
|
||||
status: DocumentAnnotationThreadStatus;
|
||||
@@ -86,7 +87,8 @@ export interface DocumentAnnotationComment {
|
||||
id: string;
|
||||
companyId: string;
|
||||
threadId: string;
|
||||
issueId: string;
|
||||
issueId: string | null;
|
||||
routineId?: string | null;
|
||||
documentId: string;
|
||||
body: string;
|
||||
authorType: IssueCommentAuthorType;
|
||||
|
||||
@@ -418,6 +418,7 @@ export type {
|
||||
Routine,
|
||||
RoutineEnvConfig,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineDescriptionDocument,
|
||||
RoutineVariable,
|
||||
RoutineVariableDefaultValue,
|
||||
RoutineRevisionSnapshotRoutineV1,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
documents,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
routineDocuments,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
anchorSnapshotToSelector,
|
||||
@@ -40,10 +41,21 @@ type IssueDocumentRow = {
|
||||
latestRevisionNumber: number;
|
||||
};
|
||||
|
||||
type RoutineDocumentRow = {
|
||||
routineId: string;
|
||||
companyId: string;
|
||||
documentId: string;
|
||||
documentKey: string;
|
||||
latestBody: string;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
};
|
||||
|
||||
const threadSelect = {
|
||||
id: documentAnnotationThreads.id,
|
||||
companyId: documentAnnotationThreads.companyId,
|
||||
issueId: documentAnnotationThreads.issueId,
|
||||
routineId: documentAnnotationThreads.routineId,
|
||||
documentId: documentAnnotationThreads.documentId,
|
||||
documentKey: documentAnnotationThreads.documentKey,
|
||||
status: documentAnnotationThreads.status,
|
||||
@@ -75,6 +87,7 @@ const commentSelect = {
|
||||
companyId: documentAnnotationComments.companyId,
|
||||
threadId: documentAnnotationComments.threadId,
|
||||
issueId: documentAnnotationComments.issueId,
|
||||
routineId: documentAnnotationComments.routineId,
|
||||
documentId: documentAnnotationComments.documentId,
|
||||
body: documentAnnotationComments.body,
|
||||
authorType: documentAnnotationComments.authorType,
|
||||
@@ -116,6 +129,27 @@ export function documentAnnotationService(db: Db) {
|
||||
.then((rows: IssueDocumentRow[]) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getRoutineDocument(
|
||||
routineId: string,
|
||||
key: string,
|
||||
dbOrTx: any = db,
|
||||
): Promise<RoutineDocumentRow | null> {
|
||||
return dbOrTx
|
||||
.select({
|
||||
routineId: routineDocuments.routineId,
|
||||
companyId: documents.companyId,
|
||||
documentId: documents.id,
|
||||
documentKey: routineDocuments.key,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
})
|
||||
.from(routineDocuments)
|
||||
.innerJoin(documents, eq(routineDocuments.documentId, documents.id))
|
||||
.where(and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key)))
|
||||
.then((rows: RoutineDocumentRow[]) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getThreadForIssue(
|
||||
issueId: string,
|
||||
documentKey: string,
|
||||
@@ -133,6 +167,23 @@ export function documentAnnotationService(db: Db) {
|
||||
.then((rows: DocumentAnnotationThread[]) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getThreadForRoutine(
|
||||
routineId: string,
|
||||
documentKey: string,
|
||||
threadId: string,
|
||||
dbOrTx: any = db,
|
||||
): Promise<DocumentAnnotationThread | null> {
|
||||
return dbOrTx
|
||||
.select(threadSelect)
|
||||
.from(documentAnnotationThreads)
|
||||
.where(and(
|
||||
eq(documentAnnotationThreads.id, threadId),
|
||||
eq(documentAnnotationThreads.routineId, routineId),
|
||||
eq(documentAnnotationThreads.documentKey, documentKey),
|
||||
))
|
||||
.then((rows: DocumentAnnotationThread[]) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function commentsForThreads(threadIds: string[], dbOrTx: any = db): Promise<DocumentAnnotationComment[]> {
|
||||
if (threadIds.length === 0) return [];
|
||||
return dbOrTx
|
||||
@@ -197,6 +248,39 @@ export function documentAnnotationService(db: Db) {
|
||||
}));
|
||||
},
|
||||
|
||||
listThreadsForRoutineDocument: async (
|
||||
routineId: string,
|
||||
key: string,
|
||||
options: { status?: "open" | "resolved" | "all"; includeComments?: boolean } = {},
|
||||
) => {
|
||||
const doc = await getRoutineDocument(routineId, key);
|
||||
if (!doc) throw notFound("Document not found");
|
||||
const conditions = [
|
||||
eq(documentAnnotationThreads.routineId, routineId),
|
||||
eq(documentAnnotationThreads.documentId, doc.documentId),
|
||||
];
|
||||
if (options.status && options.status !== "all") {
|
||||
conditions.push(eq(documentAnnotationThreads.status, options.status));
|
||||
}
|
||||
const threads: DocumentAnnotationThread[] = await db
|
||||
.select(threadSelect)
|
||||
.from(documentAnnotationThreads)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id));
|
||||
if (!options.includeComments) return threads;
|
||||
const comments = await commentsForThreads(threads.map((thread) => thread.id));
|
||||
const commentsByThread = new Map<string, DocumentAnnotationComment[]>();
|
||||
for (const comment of comments) {
|
||||
const existing = commentsByThread.get(comment.threadId) ?? [];
|
||||
existing.push(comment);
|
||||
commentsByThread.set(comment.threadId, existing);
|
||||
}
|
||||
return threads.map((thread) => ({
|
||||
...thread,
|
||||
comments: commentsByThread.get(thread.id) ?? [],
|
||||
}));
|
||||
},
|
||||
|
||||
getThreadForIssueDocument: async (issueId: string, key: string, threadId: string) => {
|
||||
const thread = await getThreadForIssue(issueId, key, threadId);
|
||||
if (!thread) return null;
|
||||
@@ -204,6 +288,13 @@ export function documentAnnotationService(db: Db) {
|
||||
return { ...thread, comments };
|
||||
},
|
||||
|
||||
getThreadForRoutineDocument: async (routineId: string, key: string, threadId: string) => {
|
||||
const thread = await getThreadForRoutine(routineId, key, threadId);
|
||||
if (!thread) return null;
|
||||
const comments = await commentsForThreads([thread.id]);
|
||||
return { ...thread, comments };
|
||||
},
|
||||
|
||||
createThread: async (
|
||||
issueId: string,
|
||||
key: string,
|
||||
@@ -291,6 +382,94 @@ export function documentAnnotationService(db: Db) {
|
||||
return { ...thread, comments: [comment] };
|
||||
}),
|
||||
|
||||
createRoutineThread: async (
|
||||
routineId: string,
|
||||
key: string,
|
||||
input: CreateDocumentAnnotationThread,
|
||||
actor: ActorInput,
|
||||
) => db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select ${documents.id}
|
||||
from ${routineDocuments}
|
||||
inner join ${documents} on ${routineDocuments.documentId} = ${documents.id}
|
||||
where ${and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key))}
|
||||
for update of ${documents}
|
||||
`);
|
||||
const doc = await getRoutineDocument(routineId, key, tx);
|
||||
if (!doc) throw notFound("Document not found");
|
||||
if (
|
||||
input.baseRevisionId !== doc.latestRevisionId
|
||||
|| input.baseRevisionNumber !== doc.latestRevisionNumber
|
||||
) {
|
||||
throw conflict("Annotation anchor requires the current document revision", {
|
||||
currentRevisionId: doc.latestRevisionId,
|
||||
currentRevisionNumber: doc.latestRevisionNumber,
|
||||
});
|
||||
}
|
||||
|
||||
const verification = verifyDocumentAnchorSelector({
|
||||
markdown: doc.latestBody,
|
||||
selector: input.selector,
|
||||
});
|
||||
if (!verification.ok || !verification.anchor) {
|
||||
throw unprocessable("Annotation anchor does not match the current document revision", {
|
||||
reason: verification.reason,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [thread] = await tx
|
||||
.insert(documentAnnotationThreads)
|
||||
.values({
|
||||
companyId: doc.companyId,
|
||||
issueId: null,
|
||||
routineId,
|
||||
documentId: doc.documentId,
|
||||
documentKey: doc.documentKey,
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: doc.latestRevisionId,
|
||||
originalRevisionNumber: doc.latestRevisionNumber,
|
||||
currentRevisionId: doc.latestRevisionId,
|
||||
currentRevisionNumber: doc.latestRevisionNumber,
|
||||
selectedText: verification.anchor.selectedText,
|
||||
prefixText: verification.anchor.prefixText,
|
||||
suffixText: verification.anchor.suffixText,
|
||||
normalizedStart: verification.anchor.normalizedStart,
|
||||
normalizedEnd: verification.anchor.normalizedEnd,
|
||||
markdownStart: verification.anchor.markdownStart,
|
||||
markdownEnd: verification.anchor.markdownEnd,
|
||||
anchorSelector: input.selector,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning(threadSelect);
|
||||
|
||||
const [comment] = await tx
|
||||
.insert(documentAnnotationComments)
|
||||
.values({
|
||||
companyId: doc.companyId,
|
||||
threadId: thread.id,
|
||||
issueId: null,
|
||||
routineId,
|
||||
documentId: doc.documentId,
|
||||
body: input.body,
|
||||
authorType: actor.actorType,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
issueCommentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning(commentSelect);
|
||||
|
||||
return { ...thread, comments: [comment] };
|
||||
}),
|
||||
|
||||
addComment: async (
|
||||
issueId: string,
|
||||
key: string,
|
||||
@@ -326,6 +505,41 @@ export function documentAnnotationService(db: Db) {
|
||||
return comment;
|
||||
}),
|
||||
|
||||
addRoutineComment: async (
|
||||
routineId: string,
|
||||
key: string,
|
||||
threadId: string,
|
||||
input: CreateDocumentAnnotationComment,
|
||||
actor: ActorInput,
|
||||
) => db.transaction(async (tx) => {
|
||||
const thread = await getThreadForRoutine(routineId, key, threadId, tx);
|
||||
if (!thread) throw notFound("Annotation thread not found");
|
||||
const now = new Date();
|
||||
const [comment] = await tx
|
||||
.insert(documentAnnotationComments)
|
||||
.values({
|
||||
companyId: thread.companyId,
|
||||
threadId: thread.id,
|
||||
issueId: null,
|
||||
routineId: thread.routineId,
|
||||
documentId: thread.documentId,
|
||||
body: input.body,
|
||||
authorType: actor.actorType,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
issueCommentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning(commentSelect);
|
||||
await tx
|
||||
.update(documentAnnotationThreads)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(documentAnnotationThreads.id, thread.id));
|
||||
return comment;
|
||||
}),
|
||||
|
||||
cleanupForIssueCommentDeletion: async (
|
||||
issueId: string,
|
||||
issueCommentId: string,
|
||||
@@ -425,6 +639,40 @@ export function documentAnnotationService(db: Db) {
|
||||
return updated;
|
||||
}),
|
||||
|
||||
updateRoutineThread: async (
|
||||
routineId: string,
|
||||
key: string,
|
||||
threadId: string,
|
||||
input: UpdateDocumentAnnotationThread,
|
||||
actor: ActorInput,
|
||||
) => db.transaction(async (tx) => {
|
||||
const thread = await getThreadForRoutine(routineId, key, threadId, tx);
|
||||
if (!thread) throw notFound("Annotation thread not found");
|
||||
if (!input.status || input.status === thread.status) return thread;
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await tx
|
||||
.update(documentAnnotationThreads)
|
||||
.set(input.status === "resolved"
|
||||
? {
|
||||
status: "resolved",
|
||||
resolvedByAgentId: actor.agentId ?? null,
|
||||
resolvedByUserId: actor.userId ?? null,
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
: {
|
||||
status: "open",
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(documentAnnotationThreads.id, thread.id))
|
||||
.returning(threadSelect);
|
||||
return updated;
|
||||
}),
|
||||
|
||||
remapOpenThreadsForDocument: async (input: {
|
||||
issueId: string;
|
||||
key: string;
|
||||
@@ -500,6 +748,81 @@ export function documentAnnotationService(db: Db) {
|
||||
return changed;
|
||||
}),
|
||||
|
||||
remapOpenThreadsForRoutineDocument: async (input: {
|
||||
routineId: string;
|
||||
key: string;
|
||||
documentId: string;
|
||||
nextRevisionId: string | null;
|
||||
nextRevisionNumber: number;
|
||||
nextBody: string;
|
||||
}) => db.transaction(async (tx) => {
|
||||
const threads: DocumentAnnotationThread[] = await tx
|
||||
.select(threadSelect)
|
||||
.from(documentAnnotationThreads)
|
||||
.where(and(
|
||||
eq(documentAnnotationThreads.routineId, input.routineId),
|
||||
eq(documentAnnotationThreads.documentId, input.documentId),
|
||||
eq(documentAnnotationThreads.status, "open"),
|
||||
));
|
||||
const changed = [];
|
||||
const now = new Date();
|
||||
|
||||
for (const thread of threads) {
|
||||
if (thread.currentRevisionId === input.nextRevisionId) continue;
|
||||
const previousAnchor = snapshotFromThread(thread);
|
||||
const remap = remapDocumentAnchor({
|
||||
previousAnchor,
|
||||
nextMarkdown: input.nextBody,
|
||||
});
|
||||
const nextAnchor = remap.anchor;
|
||||
const nextSelector = nextAnchor ? anchorSnapshotToSelector(nextAnchor) : thread.anchorSelector;
|
||||
const [updated] = await tx
|
||||
.update(documentAnnotationThreads)
|
||||
.set({
|
||||
currentRevisionId: input.nextRevisionId,
|
||||
currentRevisionNumber: input.nextRevisionNumber,
|
||||
anchorState: remap.anchorState,
|
||||
anchorConfidence: remap.confidence,
|
||||
...(nextAnchor
|
||||
? {
|
||||
selectedText: nextAnchor.selectedText,
|
||||
prefixText: nextAnchor.prefixText,
|
||||
suffixText: nextAnchor.suffixText,
|
||||
normalizedStart: nextAnchor.normalizedStart,
|
||||
normalizedEnd: nextAnchor.normalizedEnd,
|
||||
markdownStart: nextAnchor.markdownStart,
|
||||
markdownEnd: nextAnchor.markdownEnd,
|
||||
}
|
||||
: {}),
|
||||
anchorSelector: nextSelector,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(documentAnnotationThreads.id, thread.id))
|
||||
.returning(threadSelect);
|
||||
const [snapshot] = await tx
|
||||
.insert(documentAnnotationAnchorSnapshots)
|
||||
.values({
|
||||
companyId: thread.companyId,
|
||||
threadId: thread.id,
|
||||
documentId: thread.documentId,
|
||||
fromRevisionId: thread.currentRevisionId,
|
||||
fromRevisionNumber: thread.currentRevisionNumber,
|
||||
toRevisionId: input.nextRevisionId,
|
||||
toRevisionNumber: input.nextRevisionNumber,
|
||||
previousAnchor,
|
||||
nextAnchor,
|
||||
anchorState: remap.anchorState,
|
||||
anchorConfidence: remap.confidence,
|
||||
failureReason: remap.anchor ? null : remap.reason,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
changed.push({ thread: updated, snapshot });
|
||||
}
|
||||
|
||||
return changed;
|
||||
}),
|
||||
|
||||
selectorToAnchorSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -11,27 +11,50 @@ import { api } from "./client";
|
||||
|
||||
export type DocumentAnnotationListFilter = "open" | "resolved" | "all";
|
||||
|
||||
export type DocumentAnnotationTarget =
|
||||
| { kind: "issue"; issueId: string; documentKey: string }
|
||||
| { kind: "routine"; routineId: string; documentKey: "description" };
|
||||
|
||||
function issueTarget(issueId: string, documentKey: string): DocumentAnnotationTarget {
|
||||
return { kind: "issue", issueId, documentKey };
|
||||
}
|
||||
|
||||
function targetBasePath(target: DocumentAnnotationTarget) {
|
||||
if (target.kind === "routine") {
|
||||
return `/routines/${target.routineId}/description/annotations`;
|
||||
}
|
||||
return `/issues/${target.issueId}/documents/${encodeURIComponent(target.documentKey)}/annotations`;
|
||||
}
|
||||
|
||||
export const documentAnnotationsApi = {
|
||||
list: (
|
||||
issueId: string,
|
||||
key: string,
|
||||
options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {},
|
||||
) => documentAnnotationsApi.listForTarget(issueTarget(issueId, key), options),
|
||||
listForTarget: (
|
||||
target: DocumentAnnotationTarget,
|
||||
options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.status) params.set("status", options.status);
|
||||
if (options.includeComments) params.set("includeComments", "true");
|
||||
const qs = params.toString();
|
||||
return api.get<DocumentAnnotationThreadWithComments[]>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations${qs ? `?${qs}` : ""}`,
|
||||
`${targetBasePath(target)}${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
get: (issueId: string, key: string, threadId: string) =>
|
||||
documentAnnotationsApi.getForTarget(issueTarget(issueId, key), threadId),
|
||||
getForTarget: (target: DocumentAnnotationTarget, threadId: string) =>
|
||||
api.get<DocumentAnnotationThreadWithComments>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||
`${targetBasePath(target)}/${threadId}`,
|
||||
),
|
||||
create: (issueId: string, key: string, data: CreateDocumentAnnotationThreadRequest) =>
|
||||
documentAnnotationsApi.createForTarget(issueTarget(issueId, key), data),
|
||||
createForTarget: (target: DocumentAnnotationTarget, data: CreateDocumentAnnotationThreadRequest) =>
|
||||
api.post<DocumentAnnotationThreadWithComments>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations`,
|
||||
targetBasePath(target),
|
||||
data,
|
||||
),
|
||||
addComment: (
|
||||
@@ -39,9 +62,14 @@ export const documentAnnotationsApi = {
|
||||
key: string,
|
||||
threadId: string,
|
||||
data: CreateDocumentAnnotationCommentRequest,
|
||||
) => documentAnnotationsApi.addCommentForTarget(issueTarget(issueId, key), threadId, data),
|
||||
addCommentForTarget: (
|
||||
target: DocumentAnnotationTarget,
|
||||
threadId: string,
|
||||
data: CreateDocumentAnnotationCommentRequest,
|
||||
) =>
|
||||
api.post<DocumentAnnotationComment>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}/comments`,
|
||||
`${targetBasePath(target)}/${threadId}/comments`,
|
||||
data,
|
||||
),
|
||||
updateStatus: (
|
||||
@@ -49,10 +77,15 @@ export const documentAnnotationsApi = {
|
||||
key: string,
|
||||
threadId: string,
|
||||
status: DocumentAnnotationThreadStatus,
|
||||
) => documentAnnotationsApi.updateStatusForTarget(issueTarget(issueId, key), threadId, status),
|
||||
updateStatusForTarget: (
|
||||
target: DocumentAnnotationTarget,
|
||||
threadId: string,
|
||||
status: DocumentAnnotationThreadStatus,
|
||||
) => {
|
||||
const payload: UpdateDocumentAnnotationThreadRequest = { status };
|
||||
return api.patch<DocumentAnnotationThread>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||
`${targetBasePath(target)}/${threadId}`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, relativeTime } from "@/lib/utils";
|
||||
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
@@ -36,7 +36,8 @@ import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
export interface AnnotationPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
issueId: string;
|
||||
target?: DocumentAnnotationTarget;
|
||||
issueId?: string;
|
||||
documentKey: string;
|
||||
documentRevisionNumber: number;
|
||||
baseRevisionId: string | null;
|
||||
@@ -106,6 +107,11 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
|
||||
const annotationTarget = useMemo<DocumentAnnotationTarget>(() => {
|
||||
if (props.target) return props.target;
|
||||
if (!props.issueId) throw new Error("Document annotation panel requires an annotation target.");
|
||||
return { kind: "issue", issueId: props.issueId, documentKey: props.documentKey };
|
||||
}, [props.documentKey, props.issueId, props.target]);
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
@@ -137,26 +143,35 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
);
|
||||
|
||||
const annotationsQueryKey = useMemo(
|
||||
() => queryKeys.issues.documentAnnotations(props.issueId, props.documentKey, "all"),
|
||||
[props.documentKey, props.issueId],
|
||||
() => annotationTarget.kind === "routine"
|
||||
? queryKeys.routines.documentAnnotations(annotationTarget.routineId, annotationTarget.documentKey, "all")
|
||||
: queryKeys.issues.documentAnnotations(annotationTarget.issueId, annotationTarget.documentKey, "all"),
|
||||
[annotationTarget],
|
||||
);
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey)
|
||||
&& query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-annotations"
|
||||
&& query.queryKey[2] === props.issueId
|
||||
&& query.queryKey[3] === props.documentKey,
|
||||
predicate: (query) => {
|
||||
if (!Array.isArray(query.queryKey)) return false;
|
||||
if (annotationTarget.kind === "routine") {
|
||||
return query.queryKey[0] === "routines"
|
||||
&& query.queryKey[1] === "document-annotations"
|
||||
&& query.queryKey[2] === annotationTarget.routineId
|
||||
&& query.queryKey[3] === annotationTarget.documentKey;
|
||||
}
|
||||
return query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-annotations"
|
||||
&& query.queryKey[2] === annotationTarget.issueId
|
||||
&& query.queryKey[3] === annotationTarget.documentKey;
|
||||
},
|
||||
});
|
||||
}, [props.documentKey, props.issueId, queryClient]);
|
||||
}, [annotationTarget, queryClient]);
|
||||
|
||||
const createThread = useMutation({
|
||||
mutationFn: async (body: string) => {
|
||||
if (!props.pendingAnchor) throw new Error("No selection to anchor to.");
|
||||
if (!props.baseRevisionId) throw new Error("Document has no revision yet.");
|
||||
return documentAnnotationsApi.create(props.issueId, props.documentKey, {
|
||||
return documentAnnotationsApi.createForTarget(annotationTarget, {
|
||||
baseRevisionId: props.baseRevisionId,
|
||||
baseRevisionNumber: props.baseRevisionNumber,
|
||||
selector: props.pendingAnchor.selector,
|
||||
@@ -172,8 +187,8 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
const optimisticThread = buildOptimisticThread({
|
||||
body,
|
||||
selectedText: anchor.selectedText,
|
||||
issueId: props.issueId,
|
||||
documentKey: props.documentKey,
|
||||
target: annotationTarget,
|
||||
documentKey: annotationTarget.documentKey,
|
||||
baseRevisionId: props.baseRevisionId,
|
||||
baseRevisionNumber: props.baseRevisionNumber,
|
||||
normalizedStart: anchor.selector.position.normalizedStart,
|
||||
@@ -210,7 +225,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
|
||||
const addReply = useMutation({
|
||||
mutationFn: ({ threadId, body }: { threadId: string; body: string }) =>
|
||||
documentAnnotationsApi.addComment(props.issueId, props.documentKey, threadId, { body }),
|
||||
documentAnnotationsApi.addCommentForTarget(annotationTarget, threadId, { body }),
|
||||
// Optimistically append the reply so it stays on screen through the round-trip.
|
||||
onMutate: async ({ threadId, body }) => {
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
@@ -218,7 +233,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
const optimisticComment = buildOptimisticComment({
|
||||
body,
|
||||
threadId,
|
||||
issueId: props.issueId,
|
||||
target: annotationTarget,
|
||||
author: currentUser,
|
||||
});
|
||||
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
|
||||
@@ -242,7 +257,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
|
||||
const updateStatus = useMutation({
|
||||
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
|
||||
documentAnnotationsApi.updateStatus(props.issueId, props.documentKey, threadId, status),
|
||||
documentAnnotationsApi.updateStatusForTarget(annotationTarget, threadId, status),
|
||||
onMutate: async ({ threadId, status }) => {
|
||||
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
|
||||
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
|
||||
@@ -664,7 +679,7 @@ function optimisticId(prefix: string): string {
|
||||
function buildOptimisticComment(input: {
|
||||
body: string;
|
||||
threadId: string;
|
||||
issueId: string;
|
||||
target: DocumentAnnotationTarget;
|
||||
author: OptimisticAuthor;
|
||||
}): DocumentAnnotationComment {
|
||||
const now = new Date();
|
||||
@@ -672,7 +687,8 @@ function buildOptimisticComment(input: {
|
||||
id: optimisticId("optimistic-comment"),
|
||||
companyId: "",
|
||||
threadId: input.threadId,
|
||||
issueId: input.issueId,
|
||||
issueId: input.target.kind === "issue" ? input.target.issueId : null,
|
||||
routineId: input.target.kind === "routine" ? input.target.routineId : null,
|
||||
documentId: "",
|
||||
body: input.body,
|
||||
authorType: "user",
|
||||
@@ -688,7 +704,7 @@ function buildOptimisticComment(input: {
|
||||
function buildOptimisticThread(input: {
|
||||
body: string;
|
||||
selectedText: string;
|
||||
issueId: string;
|
||||
target: DocumentAnnotationTarget;
|
||||
documentKey: string;
|
||||
baseRevisionId: string;
|
||||
baseRevisionNumber: number;
|
||||
@@ -701,7 +717,7 @@ function buildOptimisticThread(input: {
|
||||
const comment = buildOptimisticComment({
|
||||
body: input.body,
|
||||
threadId: id,
|
||||
issueId: input.issueId,
|
||||
target: input.target,
|
||||
author: input.author,
|
||||
});
|
||||
// Only the fields the panel + overlay read need to be accurate; the optimistic
|
||||
@@ -709,7 +725,8 @@ function buildOptimisticThread(input: {
|
||||
// don't have to fabricate every backend-only column.
|
||||
return {
|
||||
id,
|
||||
issueId: input.issueId,
|
||||
issueId: input.target.kind === "issue" ? input.target.issueId : null,
|
||||
routineId: input.target.kind === "routine" ? input.target.routineId : null,
|
||||
documentKey: input.documentKey,
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||
import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer";
|
||||
@@ -16,9 +16,12 @@ const DESKTOP_ANNOTATION_PANEL_MIN_WIDTH = 280;
|
||||
const DESKTOP_ANNOTATION_PANEL_GAP = 24;
|
||||
const DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN = 16;
|
||||
|
||||
type AnnotationDocument = Pick<IssueDocument, "key" | "latestRevisionId" | "latestRevisionNumber">;
|
||||
|
||||
export interface IssueDocumentAnnotationsProps {
|
||||
issueId: string;
|
||||
doc: IssueDocument;
|
||||
doc: AnnotationDocument;
|
||||
target?: DocumentAnnotationTarget;
|
||||
/** The body that is being rendered/edited (current or historical revision). */
|
||||
bodyMarkdown: string;
|
||||
/** True when a draft has unsaved changes or is currently saving. */
|
||||
@@ -43,6 +46,7 @@ export interface IssueDocumentAnnotationsProps {
|
||||
export function IssueDocumentAnnotations({
|
||||
issueId,
|
||||
doc,
|
||||
target,
|
||||
bodyMarkdown,
|
||||
draftDirty,
|
||||
draftConflicted,
|
||||
@@ -152,8 +156,12 @@ export function IssueDocumentAnnotations({
|
||||
}, [doc.key, isMobile, panelOpen]);
|
||||
|
||||
const annotationsQuery = useQuery({
|
||||
queryKey: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
|
||||
queryFn: () => documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
|
||||
queryKey: target?.kind === "routine"
|
||||
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
|
||||
: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
|
||||
queryFn: () => target
|
||||
? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true })
|
||||
: documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const allThreads = annotationsQuery.data ?? [];
|
||||
@@ -284,6 +292,7 @@ export function IssueDocumentAnnotations({
|
||||
}
|
||||
}}
|
||||
issueId={issueId}
|
||||
target={target}
|
||||
documentKey={doc.key}
|
||||
documentRevisionNumber={doc.latestRevisionNumber}
|
||||
baseRevisionId={doc.latestRevisionId}
|
||||
@@ -359,6 +368,7 @@ export function IssueDocumentAnnotations({
|
||||
export interface DocumentAnnotationsCountChipProps {
|
||||
issueId: string;
|
||||
docKey: string;
|
||||
target?: DocumentAnnotationTarget;
|
||||
panelOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
@@ -370,12 +380,17 @@ export interface DocumentAnnotationsCountChipProps {
|
||||
export function DocumentAnnotationsCountChip({
|
||||
issueId,
|
||||
docKey,
|
||||
target,
|
||||
panelOpen,
|
||||
onToggle,
|
||||
}: DocumentAnnotationsCountChipProps) {
|
||||
const annotationsQuery = useQuery({
|
||||
queryKey: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
|
||||
queryFn: () => documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
|
||||
queryKey: target?.kind === "routine"
|
||||
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
|
||||
: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
|
||||
queryFn: () => target
|
||||
? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true })
|
||||
: documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const threads = annotationsQuery.data ?? [];
|
||||
|
||||
@@ -27,6 +27,7 @@ import { nextCronFires, previewFirePolicies } from "../../lib/cron-fires";
|
||||
import { timeAgo } from "../../lib/timeAgo";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
import { InlineEntitySelector } from "../InlineEntitySelector";
|
||||
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations";
|
||||
import { AgentIcon } from "../AgentIconPicker";
|
||||
import { MarkdownEditor } from "../MarkdownEditor";
|
||||
import { ScheduleEditor } from "../ScheduleEditor";
|
||||
@@ -98,8 +99,11 @@ export function OverviewSection() {
|
||||
routineRuns,
|
||||
activity,
|
||||
saveRoutine,
|
||||
saveConflict,
|
||||
isSectionDirty,
|
||||
navigateToSection,
|
||||
} = ctx;
|
||||
const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(false);
|
||||
|
||||
const activeTriggers = routine.triggers.length;
|
||||
const nextFire = useMemo(() => {
|
||||
@@ -215,20 +219,63 @@ export function OverviewSection() {
|
||||
) : null}
|
||||
|
||||
{/* Instructions */}
|
||||
<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">
|
||||
|
||||
@@ -236,6 +236,41 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes routine description annotation caches when routine annotation activity arrives", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: () => undefined,
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "routine",
|
||||
entityId: "routine-1",
|
||||
action: "routine.document_annotation_comment_added",
|
||||
actorType: "user",
|
||||
actorId: "user-2",
|
||||
details: {
|
||||
documentKey: "description",
|
||||
threadId: "thread-1",
|
||||
commentId: "comment-1",
|
||||
},
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["routines"],
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["routines", "document-annotations", "routine-1", "description"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps self-authored comment events from refetching the active issue tree", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
|
||||
@@ -422,6 +422,13 @@ const ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([
|
||||
"issue.document_annotation_thread_reopened",
|
||||
"issue.document_annotation_remapped",
|
||||
]);
|
||||
const ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([
|
||||
"routine.document_annotation_thread_created",
|
||||
"routine.document_annotation_comment_added",
|
||||
"routine.document_annotation_thread_resolved",
|
||||
"routine.document_annotation_thread_reopened",
|
||||
"routine.document_annotation_remapped",
|
||||
]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["error"]);
|
||||
const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]);
|
||||
|
||||
@@ -780,6 +787,12 @@ function invalidateActivityQueries(
|
||||
|
||||
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
|
||||
queryClient.invalidateQueries({ queryKey: ["routines"] });
|
||||
if (entityType === "routine" && action && ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action) && entityId) {
|
||||
const documentKey = readString(details?.key) ?? readString(details?.documentKey) ?? "description";
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["routines", "document-annotations", entityId, documentKey],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ export const queryKeys = {
|
||||
runs: (id: string) => ["routines", "runs", id] as const,
|
||||
revisions: (id: string) => ["routines", "revisions", id] as const,
|
||||
activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const,
|
||||
documentAnnotations: (routineId: string, key: "description", status: "open" | "resolved" | "all" = "all") =>
|
||||
["routines", "document-annotations", routineId, key, status] as const,
|
||||
},
|
||||
executionWorkspaces: {
|
||||
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
||||
|
||||
Reference in new issue
Block a user