mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
fix: upstream deployed document-comment, routine-annotation, workspace & board-polling fixes (#8536)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - This change touches several already-shipped subsystems — document-comment annotations, the selected-agent Conference Room chat surface, routine description annotations, workspace-operation tracking, and the board polling/inbox UI > - A batch of incremental fixes and two small backend additions had accumulated on a local mainline and were deployed to a live instance, but never landed upstream — so each `origin/master` sync kept re-diverging > - Leaving them un-upstreamed means the same delta has to be re-merged on every sync and risks being lost or silently reverted > - This pull request rebases that delta cleanly on top of current `origin/master` (preserving recent upstream work such as reusable sandbox leases and the relation-list collapse controls) and brings it up for review > - The benefit is that mainline and the deployed instance converge, and these fixes/additions get normal review + CI + Greptile coverage ## Linked Issues or Issue Description No single GitHub issue tracks this; it is a bundle of bug fixes and two small feature additions. Following the issue-template fields: **Bug fixes (what was wrong → what this does):** - Document comments rendered out of document order, didn't live-update across clients, swallowed save failures, and lost the markdown text selection on re-render. Now: doc-order sort, live updates, surfaced save-failure state, stable selection across re-renders. - The board polling hot path returned oversized payloads on every poll. Now: an opt-in `summary` projection trims the heartbeat-run list payload. - Assorted UI fixes: sidebar nav peek/streamlining edge cases, markdown file-viewer re-mount/line-height issues on iOS Safari, inbox badge/skill deep-link tab selection, and `⌘.` work-mode cycling on iOS. **Feature additions:** - `workspace_operations.issue_id` — associate a workspace operation with the issue that triggered it (new migration `0106`, schema, service, shared type). - Routine **description annotations** — comment threads on a routine's description document, mirroring issue document annotations (new migration `0107`, `routine_documents` schema, routes/service, editable-sections UI). - Selected-agent **Conference Room chat** surface wiring and live issue-thread updates. **Related PR:** #8229 (`feat(control-plane): add annotation and workspace controls`, draft) covers overlapping annotation/workspace-control territory — flagging it so a reviewer can reconcile the two rather than double-merging. ## What Changed - `feat(workspace)`: `workspace_operations.issue_id` migration + schema/service/type; issue workspace property controls reconciled with upstream's evolved "Service" row. - `feat(routines)`: routine description annotations — `routine_documents` schema, migration `0107`, routes/service, `editable-sections` UI. - `fix(document-comments)`: doc-order sort, live updates, save-failure surfacing, stable markdown selection (+ storybook story, rerender test). - `feat(chat)`: selected-agent Conference Room chat surface and live issue-thread updates (`LiveUpdatesProvider`, `issue-chat-messages`, interactions service). - `perf(board)`: opt-in `summary` projection for the board polling/heartbeat-run list payload, plus assorted sidebar / file-viewer / inbox / IssueProperties UI fixes. Organized into 5 logical commits. Migrations are numbered incrementally after upstream's latest (`0105`) — `0106` then `0107`, no journal collision. ## Verification - Built by 3-way merging the deployed delta onto current `origin/master`; the only merge conflict (`IssueProperties.test.tsx`, two adjacent test blocks) was resolved in favor of upstream's evolved "green service link above the workspace row" layout, which matches the merged component's rendered output. - Confirmed recent upstream work is preserved post-merge: reusable sandbox lease teardown (`#8513`), the IssueProperties relation-list collapse controls, and the sidebar streamlined-nav default. - Confirmed the net diff vs `origin/master` is exactly the intended feature delta (65 files) and that overlapping server files (`issues.ts`, `agents.ts`, `heartbeat.ts`) only add feature code without disturbing upstream logic. - This delta is already running on a live deployed instance. - Full typecheck/test suite + Greptile to run in CI (see checklist). ## Risks - Two new migrations (`0106`, `0107`). Both are additive (new table / new nullable column) and ordered after upstream's `0105`; no data backfill, low risk. If another migration-bearing PR merges first, renumber before merge. - Largest blast radius is in the merged overlapping UI/service files; covered by the existing test suites for those files plus CI. - Overlaps thematically with draft PR #8229 — reviewers should reconcile rather than merge both blindly. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, with tool use (git, shell). Agentic coding workflow. ## 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 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2dbaf4a7fa
commit
e68188c438
70 files changed
+4322
-538
No files matched your search
@@ -298,6 +298,10 @@ describe("document annotation routes", () => {
|
||||
expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "issue.document_annotation_thread_created",
|
||||
details: expect.objectContaining({
|
||||
key: "plan",
|
||||
documentKey: "plan",
|
||||
}),
|
||||
}));
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -324,6 +328,10 @@ describe("document annotation routes", () => {
|
||||
expect(mockIssueReferenceService.syncAnnotationComment).toHaveBeenCalledWith(annotationComment.id);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "issue.document_annotation_comment_added",
|
||||
details: expect.objectContaining({
|
||||
key: "plan",
|
||||
documentKey: "plan",
|
||||
}),
|
||||
}));
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
|
||||
@@ -334,6 +342,10 @@ describe("document annotation routes", () => {
|
||||
expect(resolved.body.status).toBe("resolved");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "issue.document_annotation_thread_resolved",
|
||||
details: expect.objectContaining({
|
||||
key: "plan",
|
||||
documentKey: "plan",
|
||||
}),
|
||||
}));
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -145,6 +145,85 @@ describeEmbeddedPostgres("heartbeat list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns summary list rows without heavy run detail fields", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "running",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
status: "failed",
|
||||
error: "Failed after doing useful work",
|
||||
usageJson: {
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
inputTokens: 123,
|
||||
},
|
||||
resultJson: {
|
||||
summary: "large run summary",
|
||||
stdout: "x".repeat(20_000),
|
||||
},
|
||||
sessionIdBefore: "session-before",
|
||||
sessionIdAfter: "session-after",
|
||||
logStore: "local",
|
||||
logRef: "logs/run.log",
|
||||
logSha256: "abc123",
|
||||
externalRunId: "external-run",
|
||||
processPid: 12345,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
});
|
||||
|
||||
const runs = await heartbeatService(db).list(companyId, undefined, 5, { summary: true });
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]).toMatchObject({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "failed",
|
||||
error: "Failed after doing useful work",
|
||||
usageJson: null,
|
||||
resultJson: null,
|
||||
sessionIdBefore: null,
|
||||
sessionIdAfter: null,
|
||||
logStore: null,
|
||||
logRef: null,
|
||||
logSha256: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds oversized legacy result json payloads on getRun", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
@@ -1345,13 +1345,23 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
describe("workspace_finalize accept gate", () => {
|
||||
async function seedAcceptGateFixture() {
|
||||
type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation";
|
||||
|
||||
async function seedAcceptGateFixture(options?: {
|
||||
kind?: AcceptGateInteractionKind;
|
||||
sourceRunId?: string | null;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const sourceRunId =
|
||||
options?.sourceRunId === null ? null : options?.sourceRunId ?? randomUUID();
|
||||
const foreignRunId = randomUUID();
|
||||
const kind = options?.kind ?? "request_confirmation";
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
@@ -1375,6 +1385,40 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values([
|
||||
...(sourceRunId
|
||||
? [
|
||||
{
|
||||
id: sourceRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T21:55:00.000Z"),
|
||||
finishedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: foreignRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "running",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
},
|
||||
]);
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
@@ -1404,107 +1448,63 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
executionWorkspaceId,
|
||||
});
|
||||
|
||||
const payload = kind === "request_checkbox_confirmation"
|
||||
? {
|
||||
version: 1 as const,
|
||||
prompt: "Which files should be accepted?",
|
||||
options: [
|
||||
{ id: "file-a", label: "a.txt" },
|
||||
{ id: "file-b", label: "b.txt" },
|
||||
],
|
||||
minSelected: 0,
|
||||
maxSelected: 2,
|
||||
}
|
||||
: {
|
||||
version: 1 as const,
|
||||
prompt: "Mark this issue done?",
|
||||
};
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
kind,
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Mark this issue done?",
|
||||
},
|
||||
sourceRunId,
|
||||
payload,
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id };
|
||||
return {
|
||||
companyId,
|
||||
projectId,
|
||||
executionWorkspaceId,
|
||||
issueId,
|
||||
goalId,
|
||||
interactionId: created.id,
|
||||
sourceRunId,
|
||||
foreignRunId,
|
||||
};
|
||||
}
|
||||
|
||||
it("refuses accept when the issue's latest workspace operation is not a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
// A run touched the workspace (prepare) but never recorded workspace_finalize.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("refuses accept when the latest workspace operation is a failed workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
it("allows request_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
|
||||
await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("allows accept once a successful workspace_finalize lands as the latest operation", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
@@ -1518,6 +1518,208 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses request_confirmation accept until the source run records a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
||||
await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: expect.stringContaining(
|
||||
"the run that created this interaction has not finished syncing its workspace",
|
||||
),
|
||||
details: { executionWorkspaceId, sourceRunId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows request_confirmation accept when sourceRunId is null", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } =
|
||||
await seedAcceptGateFixture({ sourceRunId: null });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows request_checkbox_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
|
||||
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-b"] },
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
result: {
|
||||
selectedOptionIds: ["file-b"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses request_checkbox_confirmation accept until the source run records a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
||||
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: expect.stringContaining(
|
||||
"the run that created this interaction has not finished syncing its workspace",
|
||||
),
|
||||
details: { executionWorkspaceId, sourceRunId },
|
||||
});
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows request_checkbox_confirmation accept when sourceRunId is null", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } =
|
||||
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation", sourceRunId: null });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
@@ -1528,11 +1730,12 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
// workspace_finalize gate (PAPA-440) must not apply here. Without this
|
||||
// carve-out the board cannot triage suggested tasks on an issue whose
|
||||
// latest workspace op is still worktree_prepare.
|
||||
const { companyId, executionWorkspaceId, issueId, goalId } = await seedAcceptGateFixture();
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, foreignRunId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-28T22:00:00.000Z"),
|
||||
|
||||
@@ -2924,6 +2924,102 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedSharedWorkspaceDependency() {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const blockerId = randomUUID();
|
||||
const dependentId = randomUUID();
|
||||
const foreignIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Shared workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Shared workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Shared exec workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockerId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Predecessor",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: dependentId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
{
|
||||
id: foreignIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Foreign in-flight issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
]);
|
||||
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
||||
|
||||
return {
|
||||
companyId,
|
||||
assigneeAgentId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
foreignIssueId,
|
||||
};
|
||||
}
|
||||
|
||||
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
@@ -3081,86 +3177,82 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gates dependents on the workspace-finalize barrier when a done blocker's execution workspace has not synced back", async () => {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
it("treats done blockers on a shared workspace as ready while a foreign issue is in-flight", async () => {
|
||||
const {
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
foreignIssueId,
|
||||
} = await seedSharedWorkspaceDependency();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Shared workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Shared workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Shared exec workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
executionWorkspaceId,
|
||||
issueId: foreignIssueId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
const blockerId = randomUUID();
|
||||
const dependentId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockerId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Predecessor",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
{
|
||||
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: dependentId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
blockerIssueIds: [blockerId],
|
||||
}),
|
||||
]);
|
||||
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
||||
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
||||
isDependencyReady: true,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
unresolvedBlockerIssueIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
// A run touched the workspace (prepare phase) but has not yet recorded
|
||||
it("ignores unattributed pre-backfill workspace operations when checking blocker readiness", async () => {
|
||||
const {
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
} = await seedSharedWorkspaceDependency();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: null,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: dependentId,
|
||||
blockerIssueIds: [blockerId],
|
||||
}),
|
||||
]);
|
||||
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
||||
isDependencyReady: true,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
unresolvedBlockerIssueIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("gates dependents on the blocker's own workspace-finalize barrier until sync-back succeeds", async () => {
|
||||
const {
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
assigneeAgentId,
|
||||
} = await seedSharedWorkspaceDependency();
|
||||
|
||||
// The blocker touched its workspace but has not yet recorded
|
||||
// workspace_finalize — the dependent must NOT wake.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: blockerId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
@@ -3177,6 +3269,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: blockerId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
@@ -3188,6 +3281,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: blockerId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -200,6 +200,7 @@ function createWorkspaceOperationRecorderDouble() {
|
||||
companyId: "company-1",
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: "run-1",
|
||||
issueId: null,
|
||||
phase: input.phase,
|
||||
command: input.command ?? null,
|
||||
cwd: input.cwd ?? null,
|
||||
|
||||
@@ -3439,7 +3439,8 @@ export function agentRoutes(
|
||||
const agentId = req.query.agentId as string | undefined;
|
||||
const limitParam = req.query.limit as string | undefined;
|
||||
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined;
|
||||
const runs = await heartbeat.list(companyId, agentId, limit);
|
||||
const summary = req.query.summary === "true" || req.query.summary === "1";
|
||||
const runs = await heartbeat.list(companyId, agentId, limit, { summary });
|
||||
res.json(runs);
|
||||
});
|
||||
|
||||
|
||||
@@ -3906,6 +3906,7 @@ export function issueRoutes(
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: thread.documentKey,
|
||||
documentKey: thread.documentKey,
|
||||
documentId: thread.documentId,
|
||||
threadId: thread.id,
|
||||
@@ -3990,6 +3991,7 @@ export function issueRoutes(
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: keyParsed.data,
|
||||
documentKey: keyParsed.data,
|
||||
threadId: comment.threadId,
|
||||
commentId: comment.id,
|
||||
@@ -4043,6 +4045,7 @@ export function issueRoutes(
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: thread.documentKey,
|
||||
documentKey: thread.documentKey,
|
||||
documentId: thread.documentId,
|
||||
threadId: thread.id,
|
||||
|
||||
@@ -4544,6 +4544,8 @@ registerCurrentRoute({
|
||||
for (const route of [
|
||||
["get", "/api/routines/{id}/revisions", "List routine revisions"],
|
||||
["post", "/api/routines/{id}/revisions/{revisionId}/restore", "Restore a routine revision"],
|
||||
["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],
|
||||
@@ -4553,6 +4555,32 @@ for (const route of [
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const pluginLocalFolderRequestSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
access: z.enum(["read", "readWrite"]).optional(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1419,6 +1419,20 @@ const heartbeatRunListColumns = {
|
||||
updatedAt: heartbeatRuns.updatedAt,
|
||||
} as const;
|
||||
|
||||
const heartbeatRunSummaryListColumns = {
|
||||
...heartbeatRunListColumns,
|
||||
usageJson: sql<Record<string, unknown> | null>`NULL`.as("usageJson"),
|
||||
sessionIdBefore: sql<string | null>`NULL`.as("sessionIdBefore"),
|
||||
sessionIdAfter: sql<string | null>`NULL`.as("sessionIdAfter"),
|
||||
logStore: sql<string | null>`NULL`.as("logStore"),
|
||||
logRef: sql<string | null>`NULL`.as("logRef"),
|
||||
logSha256: sql<string | null>`NULL`.as("logSha256"),
|
||||
externalRunId: sql<string | null>`NULL`.as("externalRunId"),
|
||||
processPid: sql<number | null>`NULL`.as("processPid"),
|
||||
processGroupId: sql<number | null>`NULL`.as("processGroupId"),
|
||||
resultJson: sql<Record<string, unknown> | null>`NULL`.as("resultJson"),
|
||||
} as const;
|
||||
|
||||
const heartbeatRunListContextColumns = {
|
||||
contextIssueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("contextIssueId"),
|
||||
contextTaskId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"),
|
||||
@@ -8831,6 +8845,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
companyId: agent.companyId,
|
||||
heartbeatRunId: run.id,
|
||||
executionWorkspaceId: existingExecutionWorkspace?.id ?? null,
|
||||
issueId,
|
||||
});
|
||||
const executionWorkspaceBase = {
|
||||
baseCwd: resolvedWorkspace.cwd,
|
||||
@@ -11939,11 +11954,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
|
||||
return {
|
||||
list: async (companyId: string, agentId?: string, limit?: number) => {
|
||||
list: async (
|
||||
companyId: string,
|
||||
agentId?: string,
|
||||
limit?: number,
|
||||
options: { summary?: boolean } = {},
|
||||
) => {
|
||||
const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase();
|
||||
const summary = options.summary === true;
|
||||
const query = db
|
||||
.select(
|
||||
safeForLegacyEncoding
|
||||
summary
|
||||
? {
|
||||
...heartbeatRunSummaryListColumns,
|
||||
...heartbeatRunListContextColumns,
|
||||
}
|
||||
: safeForLegacyEncoding
|
||||
? {
|
||||
...heartbeatRunListColumns,
|
||||
error: sql<string | null>`NULL`.as("error"),
|
||||
@@ -12004,7 +12030,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
wakeSource: contextWakeSource,
|
||||
wakeTriggerDetail: contextWakeTriggerDetail,
|
||||
}),
|
||||
resultJson: safeForLegacyEncoding
|
||||
resultJson: safeForLegacyEncoding || summary
|
||||
? null
|
||||
: summarizeHeartbeatRunListResultJson({
|
||||
summary: resultSummary,
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
suggestTasksResultSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { issueService, listUnfinalizedExecutionWorkspaceIds } from "./issues.js";
|
||||
import { issueService, runWorkspaceIsFinalized } from "./issues.js";
|
||||
|
||||
type InteractionActor = {
|
||||
agentId?: string | null;
|
||||
@@ -541,7 +541,10 @@ export function issueThreadInteractionService(db: Db) {
|
||||
async function assertIssueWorkspaceFinalizedForAccept(args: {
|
||||
db: Pick<Db, "select">;
|
||||
issue: { id: string; companyId: string };
|
||||
sourceRunId: string | null;
|
||||
}) {
|
||||
if (!args.sourceRunId) return;
|
||||
|
||||
const executionWorkspaceId = await args.db
|
||||
.select({ executionWorkspaceId: issues.executionWorkspaceId })
|
||||
.from(issues)
|
||||
@@ -550,17 +553,18 @@ export function issueThreadInteractionService(db: Db) {
|
||||
|
||||
if (!executionWorkspaceId) return;
|
||||
|
||||
const unfinalized = await listUnfinalizedExecutionWorkspaceIds(
|
||||
const isFinalized = await runWorkspaceIsFinalized(
|
||||
args.db,
|
||||
args.issue.companyId,
|
||||
[executionWorkspaceId],
|
||||
executionWorkspaceId,
|
||||
args.sourceRunId,
|
||||
);
|
||||
if (!unfinalized.has(executionWorkspaceId)) return;
|
||||
if (isFinalized) return;
|
||||
|
||||
throw conflict(
|
||||
"Cannot accept interaction: the issue's most recent run has not completed workspace_finalize. "
|
||||
"Cannot accept interaction: the run that created this interaction has not finished syncing its workspace. "
|
||||
+ "Retry once the local worktree has finished syncing.",
|
||||
{ executionWorkspaceId },
|
||||
{ executionWorkspaceId, sourceRunId: args.sourceRunId },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -870,7 +874,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
// workspace_finalize gate (PAPA-440) does not apply here.
|
||||
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
|
||||
case "request_confirmation": {
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId });
|
||||
const accepted = await acceptRequestConfirmation({
|
||||
issue,
|
||||
current,
|
||||
@@ -884,7 +888,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
};
|
||||
}
|
||||
case "request_checkbox_confirmation": {
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId });
|
||||
const accepted = await acceptRequestConfirmation({
|
||||
issue,
|
||||
current,
|
||||
|
||||
@@ -689,6 +689,94 @@ export async function listUnfinalizedExecutionWorkspaceIds(
|
||||
return unfinalized;
|
||||
}
|
||||
|
||||
async function listPendingFinalizeBlockerIssueIds(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
blockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }>,
|
||||
): Promise<Set<string>> {
|
||||
const pending = new Set<string>();
|
||||
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 rows = await dbOrTx
|
||||
.select({
|
||||
issueId: workspaceOperations.issueId,
|
||||
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
|
||||
phase: workspaceOperations.phase,
|
||||
status: workspaceOperations.status,
|
||||
startedAt: workspaceOperations.startedAt,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceOperations.companyId, companyId),
|
||||
inArray(workspaceOperations.issueId, blockerIssueIds),
|
||||
inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds),
|
||||
),
|
||||
);
|
||||
|
||||
const latestByBlockerWorkspace = 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 (!current || row.startedAt > current.startedAt) {
|
||||
latestByBlockerWorkspace.set(key, {
|
||||
phase: row.phase,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue;
|
||||
pending.add(pair.blockerIssueId);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a specific run's operations on a specific execution workspace
|
||||
* reached the workspace_finalize barrier.
|
||||
*
|
||||
* Runs with no operations on the workspace are considered finalized because
|
||||
* they never touched the workspace state that accept/review gates protect.
|
||||
*/
|
||||
export async function runWorkspaceIsFinalized(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
executionWorkspaceId: string,
|
||||
runId: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
phase: workspaceOperations.phase,
|
||||
status: workspaceOperations.status,
|
||||
startedAt: workspaceOperations.startedAt,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceOperations.companyId, companyId),
|
||||
eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId),
|
||||
eq(workspaceOperations.heartbeatRunId, runId),
|
||||
),
|
||||
);
|
||||
|
||||
let latest: { phase: string; status: string; startedAt: Date } | null = null;
|
||||
for (const row of rows) {
|
||||
if (!latest || row.startedAt > latest.startedAt) latest = row;
|
||||
}
|
||||
|
||||
if (!latest) return true;
|
||||
return latest.phase === "workspace_finalize" && latest.status === "succeeded";
|
||||
}
|
||||
|
||||
async function listIssueDependencyReadinessMap(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
@@ -718,19 +806,22 @@ async function listIssueDependencyReadinessMap(
|
||||
),
|
||||
);
|
||||
|
||||
// Collect executionWorkspaceIds of "done" blockers — these are the only ones
|
||||
// Collect issue/workspace pairs of "done" blockers — these are the only ones
|
||||
// subject to the workspace-finalize barrier. Blockers that aren't done already
|
||||
// mark the dependent as not-ready and don't need a finalize check.
|
||||
const doneBlockerWorkspaceIds = new Set<string>();
|
||||
const doneBlockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }> = [];
|
||||
for (const row of blockerRows) {
|
||||
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
|
||||
doneBlockerWorkspaceIds.add(row.blockerExecutionWorkspaceId);
|
||||
doneBlockerWorkspacePairs.push({
|
||||
blockerIssueId: row.blockerIssueId,
|
||||
executionWorkspaceId: row.blockerExecutionWorkspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
const unfinalizedWorkspaceIds = await listUnfinalizedExecutionWorkspaceIds(
|
||||
const pendingFinalizeBlockerIssueIds = await listPendingFinalizeBlockerIssueIds(
|
||||
dbOrTx,
|
||||
companyId,
|
||||
[...doneBlockerWorkspaceIds],
|
||||
doneBlockerWorkspacePairs,
|
||||
);
|
||||
|
||||
for (const row of blockerRows) {
|
||||
@@ -745,7 +836,7 @@ async function listIssueDependencyReadinessMap(
|
||||
current.isDependencyReady = false;
|
||||
} else if (
|
||||
row.blockerExecutionWorkspaceId &&
|
||||
unfinalizedWorkspaceIds.has(row.blockerExecutionWorkspaceId)
|
||||
pendingFinalizeBlockerIssueIds.has(row.blockerIssueId)
|
||||
) {
|
||||
// Workspace-finalize barrier: the blocker's most recent run on its
|
||||
// execution workspace hasn't recorded a successful workspace_finalize.
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -16,6 +16,7 @@ function toWorkspaceOperation(row: WorkspaceOperationRow): WorkspaceOperation {
|
||||
companyId: row.companyId,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
heartbeatRunId: row.heartbeatRunId ?? null,
|
||||
issueId: row.issueId ?? null,
|
||||
phase: row.phase as WorkspaceOperationPhase,
|
||||
command: row.command ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
@@ -89,6 +90,7 @@ export function workspaceOperationService(db: Db) {
|
||||
companyId: string;
|
||||
heartbeatRunId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
issueId?: string | null;
|
||||
}): WorkspaceOperationRecorder {
|
||||
let executionWorkspaceId = input.executionWorkspaceId ?? null;
|
||||
const createdIds: string[] = [];
|
||||
@@ -136,6 +138,7 @@ export function workspaceOperationService(db: Db) {
|
||||
companyId: input.companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
phase: recordInput.phase,
|
||||
command: recordInput.command ?? null,
|
||||
cwd: recordInput.cwd ?? null,
|
||||
|
||||
Reference in new issue
Block a user