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:
parent
e68188c438
commit
746e287e33
|
|
@ -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`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue