feat(control-plane): add annotation and workspace controls (#8229)

## Thinking Path

> - Paperclip is the open source app people use to manage AI-agent
companies.
> - The control plane coordinates issues, workspaces, documents,
routines, and board review flows across company-scoped data.
> - The local source branch contained related schema, service, and UI
changes for workspace issue scoping and document/routine annotations.
> - These changes need to move together because db schema, shared types,
server services, and UI consumers form one contract.
> - This pull request extracts the migration-bearing control-plane work
from the source branch onto `origin/master`.
> - The benefit is a standalone branch with deterministic migration
order and focused review for the highest-risk part of the split.

## Linked Issues or Issue Description

No GitHub issue exists for this branch split. Internal source task:
[PAP-11234](/PAP/issues/PAP-11234).

Problem/motivation:
- Workspace operations need explicit issue scoping so readiness and
blocker handling can be derived correctly.
- Document annotations need reliable live updates, save failure
surfacing, normalized activity keys, and better comment panel behavior.
- Routine descriptions need the same annotation contract as issue
documents so operators can discuss and edit routine text without
special-case infrastructure.

Proposed solution:
- Add the workspace-operation `issueId` migration and readiness scoping.
- Add routine document/annotation schema, shared types, services,
routes, and UI editing support.
- Keep the related migrations in one PR so the renumbered `0106` and
`0107` migrations land in a deterministic order after current `master`.

Alternatives considered:
- Split migrations into separate PRs, rejected because that would create
migration-numbering conflicts and make each branch less standalone.
- Merge this with UI polish, rejected because this branch needs deeper
server/db review.

Roadmap alignment:
- Checked `ROADMAP.md`; the roadmap mentions future recurring routine
capabilities generally, but no duplicate implementation PR for these
annotation/workspace changes was found.

## What Changed

- Added `0106_workspace_operations_issue_id.sql` and
`0107_routine_description_annotations.sql`, plus schema exports.
- Scoped workspace readiness to blocker issues and attached workspace
operation issue ids.
- Scoped issue-thread interaction accept finalization to the source run.
- Added routine document annotation contracts across
db/shared/server/UI.
- Improved document annotation live updates, activity-key normalization,
save failure surfacing, and comment panel behavior.
- Added issue workspace property controls and compact
blocked-by/quick-control UI updates.
- Added focused server and UI regression tests for the new contracts.

## Verification

- `CI=true NODE_ENV=development pnpm install --frozen-lockfile
--prefer-offline`
- `NODE_ENV=test pnpm exec vitest
server/src/__tests__/document-annotation-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/__tests__/issues-service.test.ts
server/src/__tests__/routine-document-annotation-routes.test.ts
server/src/__tests__/routines-routes.test.ts
server/src/__tests__/workspace-runtime.test.ts
ui/src/components/IssueDocumentAnnotations.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/WorkspaceRuntimeControls.test.tsx
ui/src/context/LiveUpdatesProvider.test.ts --run` — 10 files, 273 tests
passed.
- `NODE_ENV=test pnpm -r --filter @paperclipai/db --filter
@paperclipai/shared --filter @paperclipai/server --filter
@paperclipai/ui typecheck` — passed, including db migration numbering
check.

## Risks

- Migration-bearing PR; merge this branch before any later PR that adds
migrations with higher numbers.
- Cross-layer contract risk across db/shared/server/ui, mitigated with
targeted tests and affected-package typecheck.
- Review should pay special attention to company scoping in new
routine/document annotation paths.

> 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 GPT-5 Codex via Paperclip `codex_local` / CodexCoder, GPT-5-class
coding model with tool use and shell execution. Exact runtime snapshot
and context-window setting were not exposed by the Paperclip run
context.

## 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 available from the run context)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [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 (N/A per source task: do not add screenshots/images unless
specifically part of the work)
- [x] I have updated relevant documentation to reflect my changes (N/A;
no public docs changed)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-06-23 12:00:03 -05:00 committed by GitHub
parent e68188c438
commit 746e287e33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 165 additions and 19 deletions

View File

@ -51,9 +51,9 @@ export const documentAnnotationComments = pgTable(
),
issueCommentIdx: index("document_annotation_comments_issue_comment_idx").on(table.issueCommentId),
bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
ownerCheck: check(
"document_annotation_comments_owner_check",
sql`${table.issueId} IS NOT NULL OR ${table.routineId} IS NOT NULL`,
exactlyOneOwnerChk: check(
"document_annotation_comments_exactly_one_owner_chk",
sql`num_nonnulls(${table.issueId}, ${table.routineId}) = 1`,
),
}),
);

View File

@ -74,9 +74,9 @@ export const documentAnnotationThreads = pgTable(
table.companyId,
table.anchorState,
),
ownerCheck: check(
"document_annotation_threads_owner_check",
sql`${table.issueId} IS NOT NULL OR ${table.routineId} IS NOT NULL`,
exactlyOneOwnerChk: check(
"document_annotation_threads_exactly_one_owner_chk",
sql`num_nonnulls(${table.issueId}, ${table.routineId}) = 1`,
),
}),
);

View File

@ -146,6 +146,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
await db.delete(environments);
await db.delete(workspaceOperations);
await db.delete(executionWorkspaces);
await db.delete(environmentLeases);
await db.delete(companySkills);
await db.delete(companies);
});

View File

@ -3208,7 +3208,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
});
});
it("ignores unattributed pre-backfill workspace operations when checking blocker readiness", async () => {
it("keeps dependents blocked on unattributed workspace operations for the blocker workspace", async () => {
const {
companyId,
executionWorkspaceId,
@ -3225,6 +3225,22 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
startedAt: new Date("2026-05-23T22:00:00.000Z"),
});
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([]);
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
isDependencyReady: false,
pendingFinalizeBlockerIssueIds: [blockerId],
unresolvedBlockerIssueIds: [blockerId],
});
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: null,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
});
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({
id: dependentId,
@ -3286,6 +3302,14 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
});
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: null,
phase: "worktree_prepare",
status: "succeeded",
startedAt: new Date("2026-05-23T22:15:00.000Z"),
});
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
expect.objectContaining({

View File

@ -7,10 +7,13 @@ import {
agents,
companies,
createDb,
documentRevisions,
documents,
issues,
pluginManagedResources,
plugins,
projects,
routineDocuments,
routineRuns,
routineTriggers,
routines,
@ -108,7 +111,10 @@ describeEmbeddedPostgres("plugin-managed routines", () => {
afterEach(async () => {
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routineDocuments);
await db.delete(routines);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issues);
await db.delete(agentConfigRevisions);
await db.delete(activityLog);

View File

@ -25,7 +25,10 @@ import {
companySecrets,
companySecretVersions,
createDb,
documentRevisions,
documents,
projects,
routineDocuments,
routineRuns,
routines,
secretAccessEvents,
@ -61,7 +64,10 @@ describeEmbedded("PAP-9522 QA: routine secrets end-to-end", () => {
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(routineRuns);
await db.delete(routineDocuments);
await db.delete(routines);
await db.delete(documents);
await db.delete(documentRevisions);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(projects);

View File

@ -5,11 +5,14 @@ import {
agents,
companies,
createDb,
documentRevisions,
documents,
executionWorkspaces,
heartbeatRuns,
issues,
projectWorkspaces,
projects,
routineDocuments,
routineRuns,
routines,
routineTriggers,
@ -54,7 +57,10 @@ describeEmbeddedPostgres("routine run telemetry", () => {
vi.clearAllMocks();
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routineDocuments);
await db.delete(routines);
await db.delete(documents);
await db.delete(documentRevisions);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(executionWorkspaces);

View File

@ -10,6 +10,11 @@ import {
companies,
companyMemberships,
createDb,
documentAnnotationAnchorSnapshots,
documentAnnotationComments,
documentAnnotationThreads,
documentRevisions,
documents,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
@ -18,6 +23,7 @@ import {
principalPermissionGrants,
projectWorkspaces,
projects,
routineDocuments,
routineRuns,
routines,
routineTriggers,
@ -96,6 +102,9 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
afterEach(async () => {
await db.delete(activityLog);
await db.delete(documentAnnotationAnchorSnapshots);
await db.delete(documentAnnotationComments);
await db.delete(documentAnnotationThreads);
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(heartbeatRunEvents);
@ -106,7 +115,10 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
await db.delete(projectWorkspaces);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(routineDocuments);
await db.delete(routines);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);

View File

@ -9,6 +9,8 @@ import {
companySecrets,
companySecretVersions,
createDb,
documentRevisions,
documents,
executionWorkspaces,
heartbeatRuns,
instanceSettings,
@ -17,6 +19,7 @@ import {
issues,
projectWorkspaces,
projects,
routineDocuments,
routineRuns,
routines,
routineTriggers,
@ -65,6 +68,9 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routines);
await db.delete(routineDocuments);
await db.delete(documents);
await db.delete(documentRevisions);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(heartbeatRuns);

View File

@ -621,6 +621,8 @@ const CREATED_OPERATIONS = new Set([
"POST /api/companies/{companyId}/labels",
"POST /api/issues/{id}/documents/{key}/annotations",
"POST /api/issues/{id}/documents/{key}/annotations/{threadId}/comments",
"POST /api/routines/{id}/description/annotations",
"POST /api/routines/{id}/description/annotations/{threadId}/comments",
"POST /api/issues/{id}/work-products",
"POST /api/issues/{id}/low-trust/promotions",
"POST /api/issues/{id}/approvals",
@ -4504,6 +4506,44 @@ registerCurrentRoute({
body: updateDocumentAnnotationThreadSchema,
});
for (const route of [
["get", "/api/routines/{id}/description/annotations", "List routine description annotation threads"],
["get", "/api/routines/{id}/description/annotations/{threadId}", "Get a routine description annotation thread"],
] as const) {
registerCurrentRoute({
method: route[0],
path: route[1],
tags: ["routines"],
summary: route[2],
});
}
registerCurrentRoute({
method: "post",
path: "/api/routines/{id}/description/annotations",
tags: ["routines"],
summary: "Create a routine description annotation thread",
body: createDocumentAnnotationThreadSchema,
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound },
});
registerCurrentRoute({
method: "post",
path: "/api/routines/{id}/description/annotations/{threadId}/comments",
tags: ["routines"],
summary: "Add a routine description annotation comment",
body: createDocumentAnnotationCommentSchema,
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound },
});
registerCurrentRoute({
method: "patch",
path: "/api/routines/{id}/description/annotations/{threadId}",
tags: ["routines"],
summary: "Update a routine description annotation thread",
body: updateDocumentAnnotationThreadSchema,
});
registerCurrentRoute({
method: "get",
path: "/api/issues/{id}/recovery-actions",

View File

@ -146,7 +146,11 @@ export function documentAnnotationService(db: Db) {
})
.from(routineDocuments)
.innerJoin(documents, eq(routineDocuments.documentId, documents.id))
.where(and(eq(routineDocuments.routineId, routineId), eq(routineDocuments.key, key)))
.where(and(
eq(routineDocuments.routineId, routineId),
eq(routineDocuments.key, key),
eq(routineDocuments.companyId, documents.companyId),
))
.then((rows: RoutineDocumentRow[]) => rows[0] ?? null);
}
@ -171,6 +175,8 @@ export function documentAnnotationService(db: Db) {
routineId: string,
documentKey: string,
threadId: string,
companyId: string,
documentId: string,
dbOrTx: any = db,
): Promise<DocumentAnnotationThread | null> {
return dbOrTx
@ -178,7 +184,9 @@ export function documentAnnotationService(db: Db) {
.from(documentAnnotationThreads)
.where(and(
eq(documentAnnotationThreads.id, threadId),
eq(documentAnnotationThreads.companyId, companyId),
eq(documentAnnotationThreads.routineId, routineId),
eq(documentAnnotationThreads.documentId, documentId),
eq(documentAnnotationThreads.documentKey, documentKey),
))
.then((rows: DocumentAnnotationThread[]) => rows[0] ?? null);
@ -256,6 +264,7 @@ export function documentAnnotationService(db: Db) {
const doc = await getRoutineDocument(routineId, key);
if (!doc) throw notFound("Document not found");
const conditions = [
eq(documentAnnotationThreads.companyId, doc.companyId),
eq(documentAnnotationThreads.routineId, routineId),
eq(documentAnnotationThreads.documentId, doc.documentId),
];
@ -289,7 +298,9 @@ export function documentAnnotationService(db: Db) {
},
getThreadForRoutineDocument: async (routineId: string, key: string, threadId: string) => {
const thread = await getThreadForRoutine(routineId, key, threadId);
const doc = await getRoutineDocument(routineId, key);
if (!doc) return null;
const thread = await getThreadForRoutine(routineId, key, threadId, doc.companyId, doc.documentId);
if (!thread) return null;
const comments = await commentsForThreads([thread.id]);
return { ...thread, comments };
@ -512,7 +523,9 @@ export function documentAnnotationService(db: Db) {
input: CreateDocumentAnnotationComment,
actor: ActorInput,
) => db.transaction(async (tx) => {
const thread = await getThreadForRoutine(routineId, key, threadId, tx);
const doc = await getRoutineDocument(routineId, key, tx);
if (!doc) throw notFound("Document not found");
const thread = await getThreadForRoutine(routineId, key, threadId, doc.companyId, doc.documentId, tx);
if (!thread) throw notFound("Annotation thread not found");
const now = new Date();
const [comment] = await tx
@ -646,7 +659,9 @@ export function documentAnnotationService(db: Db) {
input: UpdateDocumentAnnotationThread,
actor: ActorInput,
) => db.transaction(async (tx) => {
const thread = await getThreadForRoutine(routineId, key, threadId, tx);
const doc = await getRoutineDocument(routineId, key, tx);
if (!doc) throw notFound("Document not found");
const thread = await getThreadForRoutine(routineId, key, threadId, doc.companyId, doc.documentId, tx);
if (!thread) throw notFound("Annotation thread not found");
if (!input.status || input.status === thread.status) return thread;

View File

@ -698,6 +698,9 @@ async function listPendingFinalizeBlockerIssueIds(
const blockerIssueIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.blockerIssueId))];
const executionWorkspaceIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.executionWorkspaceId))];
if (blockerIssueIds.length === 0 || executionWorkspaceIds.length === 0) return pending;
const blockerWorkspaceKeys = new Set(
blockerWorkspacePairs.map((pair) => `${pair.blockerIssueId}:${pair.executionWorkspaceId}`),
);
const rows = await dbOrTx
.select({
@ -711,18 +714,32 @@ async function listPendingFinalizeBlockerIssueIds(
.where(
and(
eq(workspaceOperations.companyId, companyId),
inArray(workspaceOperations.issueId, blockerIssueIds),
inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds),
or(inArray(workspaceOperations.issueId, blockerIssueIds), isNull(workspaceOperations.issueId)),
),
);
const latestByBlockerWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
const latestAttributedByBlockerWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
const latestUnattributedByWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
for (const row of rows) {
if (!row.issueId || !row.executionWorkspaceId) continue;
const key = `${row.issueId}:${row.executionWorkspaceId}`;
const current = latestByBlockerWorkspace.get(key);
if (!row.executionWorkspaceId) continue;
if (row.issueId) {
const key = `${row.issueId}:${row.executionWorkspaceId}`;
if (!blockerWorkspaceKeys.has(key)) continue;
const current = latestAttributedByBlockerWorkspace.get(key);
if (!current || row.startedAt > current.startedAt) {
latestAttributedByBlockerWorkspace.set(key, {
phase: row.phase,
status: row.status,
startedAt: row.startedAt,
});
}
continue;
}
const current = latestUnattributedByWorkspace.get(row.executionWorkspaceId);
if (!current || row.startedAt > current.startedAt) {
latestByBlockerWorkspace.set(key, {
latestUnattributedByWorkspace.set(row.executionWorkspaceId, {
phase: row.phase,
status: row.status,
startedAt: row.startedAt,
@ -731,8 +748,9 @@ async function listPendingFinalizeBlockerIssueIds(
}
for (const pair of blockerWorkspacePairs) {
const latest = latestByBlockerWorkspace.get(`${pair.blockerIssueId}:${pair.executionWorkspaceId}`);
if (!latest) continue; // no attributed ops recorded -> nothing to finalize for this blocker
const latest = latestAttributedByBlockerWorkspace.get(`${pair.blockerIssueId}:${pair.executionWorkspaceId}`)
?? latestUnattributedByWorkspace.get(pair.executionWorkspaceId);
if (!latest) continue; // no ops recorded -> nothing to finalize for this blocker
if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue;
pending.add(pair.blockerIssueId);
}

View File

@ -654,6 +654,12 @@ export function routineService(
actor: Actor,
options: { changeSummary?: string | null } = {},
): Promise<RoutineDescriptionDocument> {
if (executor === db) {
return db.transaction(async (tx) => (
upsertRoutineDescriptionDocument(tx as unknown as Db, routine, actor, options)
));
}
const now = new Date();
const body = routine.description ?? "";
const existing = await getRoutineDescriptionDocument(routine.id, executor);

View File

@ -667,6 +667,9 @@ function invalidateActivityQueries(
const actorType = readString(payload.actorType);
const actorId = readString(payload.actorId);
const details = readRecord(payload.details);
const ownActorActivity =
(actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) ||
(actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId);
if (action?.startsWith("resource_membership.")) {
const targetUserId = readString(details?.userId);
@ -789,8 +792,10 @@ function invalidateActivityQueries(
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";
const routineInvalidationOptions = ownActorActivity ? { refetchType: "inactive" as const } : undefined;
queryClient.invalidateQueries({
queryKey: ["routines", "document-annotations", entityId, documentKey],
...routineInvalidationOptions,
});
}
return;