b7545823be
## Thinking Path > - Paperclip orchestrates AI-agent companies through issues, documents, runs, and durable company-scoped state. > - Issue documents are where agents and operators capture plans, handoffs, and work products. > - Before this change, document collaboration could only happen through whole-document edits and detached issue comments. > - Inline document annotations need stable anchors, revision-aware persistence, and UI affordances that do not break existing document editing. > - This pull request adds company-scoped document annotation threads, comments, anchor snapshots, API routes, and board UI. > - The benefit is that operators and agents can discuss specific document passages without losing context as documents evolve. ## What Changed - Added document annotation tables, schema exports, shared types, validators, anchor hashing, and text-anchor helpers. - Added server-side document annotation services and issue routes for listing, creating, commenting, resolving, and reopening annotation threads. - Included annotation summaries in relevant issue document reads and backup/recovery document workspace behavior. - Added React UI for inline document highlights, comment panels, mobile sheet behavior, deep-link focus, and resolved/open filtering. - Added annotation design artifacts, Storybook coverage, screenshots, and a screenshot helper script. - Rebased the branch onto current `paperclipai/paperclip` `master` and renumbered the annotation migration from `0085_old_swarm` to `0091_old_swarm`; the SQL uses `IF NOT EXISTS` guards so environments that previously applied the old migration number can safely apply the new one. - Adjusted the new annotation UI tests to use a local async flush helper because this workspace's React 19.2.4 export does not expose `React.act`. ## Verification - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/document-anchors.test.ts server/src/__tests__/document-annotation-routes.test.ts server/src/__tests__/document-annotations-service.test.ts ui/src/components/DocumentAnnotationLayer.test.tsx ui/src/components/IssueDocumentAnnotations.test.tsx ui/src/lib/document-annotation-hash.test.ts ui/src/lib/document-annotation-selection.test.ts` - Confirmed `git diff --check` passes. - Confirmed no `pnpm-lock.yaml` or `.github/workflows/*` files are included in the PR diff. ## Risks - Medium risk: this adds new persisted annotation tables and routes across db/shared/server/ui. - Migration risk is reduced by moving the branch migration to `0091_old_swarm` after upstream `0090_resource_memberships` and keeping the SQL idempotent for old `0085_old_swarm` adopters. - UI risk is mostly around text range anchoring and panel positioning across long documents, folded content, and mobile layouts; the PR includes focused unit coverage and design screenshots. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-using software engineering mode. Context window size is not exposed in this Paperclip runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
184 lines
5.7 KiB
TypeScript
184 lines
5.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
companies,
|
|
createDb,
|
|
documentAnnotationAnchorSnapshots,
|
|
documentAnnotationComments,
|
|
documentAnnotationThreads,
|
|
documentRevisions,
|
|
documents,
|
|
issueDocuments,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { documentAnnotationService } from "../services/document-annotations.js";
|
|
import { documentService } from "../services/documents.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres document annotation service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
describeEmbeddedPostgres("documentAnnotationService", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let annotations!: ReturnType<typeof documentAnnotationService>;
|
|
let docs!: ReturnType<typeof documentService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-document-annotations-");
|
|
db = createDb(tempDb.connectionString);
|
|
annotations = documentAnnotationService(db);
|
|
docs = documentService(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(documentAnnotationAnchorSnapshots);
|
|
await db.delete(documentAnnotationComments);
|
|
await db.delete(documentAnnotationThreads);
|
|
await db.delete(documentRevisions);
|
|
await db.delete(issueDocuments);
|
|
await db.delete(documents);
|
|
await db.delete(issues);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function createIssueWithDocument() {
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
identifier: "PAP-9442",
|
|
title: "Annotation race",
|
|
description: "Validate annotation revision guards",
|
|
status: "in_progress",
|
|
priority: "high",
|
|
});
|
|
|
|
const created = await docs.upsertIssueDocument({
|
|
issueId,
|
|
key: "plan",
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "Alpha selected text omega",
|
|
});
|
|
|
|
return { companyId, issueId, document: created.document };
|
|
}
|
|
|
|
it("fails closed when a concurrent document update wins before annotation thread creation commits", async () => {
|
|
const { companyId, issueId, document } = await createIssueWithDocument();
|
|
const concurrentUpdateCanCommit = deferred<void>();
|
|
const concurrentUpdateHasWritten = deferred<void>();
|
|
|
|
const concurrentUpdate = db.transaction(async (tx) => {
|
|
const now = new Date();
|
|
const [revision] = await tx
|
|
.insert(documentRevisions)
|
|
.values({
|
|
companyId,
|
|
documentId: document.id,
|
|
revisionNumber: document.latestRevisionNumber + 1,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "Alpha changed text omega",
|
|
changeSummary: "Concurrent edit",
|
|
createdAt: now,
|
|
})
|
|
.returning();
|
|
|
|
await tx
|
|
.update(documents)
|
|
.set({
|
|
latestBody: "Alpha changed text omega",
|
|
latestRevisionId: revision.id,
|
|
latestRevisionNumber: document.latestRevisionNumber + 1,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(documents.id, document.id));
|
|
|
|
concurrentUpdateHasWritten.resolve();
|
|
await concurrentUpdateCanCommit.promise;
|
|
});
|
|
|
|
await concurrentUpdateHasWritten.promise;
|
|
|
|
let annotationSettled = false;
|
|
const annotationResult = annotations
|
|
.createThread(
|
|
issueId,
|
|
"plan",
|
|
{
|
|
baseRevisionId: document.latestRevisionId!,
|
|
baseRevisionNumber: document.latestRevisionNumber,
|
|
selector: {
|
|
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
|
|
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
|
|
},
|
|
body: "Please review this text",
|
|
},
|
|
{ actorType: "user", actorId: "board-user", userId: "board-user" },
|
|
)
|
|
.then(
|
|
() => ({ status: "fulfilled" as const }),
|
|
(error: unknown) => ({ status: "rejected" as const, error }),
|
|
)
|
|
.finally(() => {
|
|
annotationSettled = true;
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(annotationSettled).toBe(false);
|
|
|
|
concurrentUpdateCanCommit.resolve();
|
|
await concurrentUpdate;
|
|
|
|
const result = await annotationResult;
|
|
expect(result.status).toBe("rejected");
|
|
if (result.status === "rejected") {
|
|
expect(result.error).toMatchObject({
|
|
status: 409,
|
|
message: "Annotation anchor requires the current document revision",
|
|
details: {
|
|
currentRevisionNumber: 2,
|
|
},
|
|
});
|
|
}
|
|
|
|
const threads = await db.select().from(documentAnnotationThreads);
|
|
expect(threads).toHaveLength(0);
|
|
});
|
|
});
|