From 057b901240d8da227a3cb7d4a7a316fa05b202d5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 09:35:24 -0500 Subject: [PATCH] feat(runner): bind revisioned document writes --- ...aperclip-runner-protocol-binding-roster.md | 12 ++-- .../src/catalog/canonical-operations.ts | 1 + .../paperclip-runner-tool-authority.test.ts | 35 ++++++++++- .../paperclip-runner-tool-authority.ts | 59 ++++++++++++++++++- 4 files changed, 97 insertions(+), 10 deletions(-) diff --git a/doc/plans/paperclip-runner-protocol-binding-roster.md b/doc/plans/paperclip-runner-protocol-binding-roster.md index d6ef760cc15..53806a72139 100644 --- a/doc/plans/paperclip-runner-protocol-binding-roster.md +++ b/doc/plans/paperclip-runner-protocol-binding-roster.md @@ -6,9 +6,9 @@ This roster is the implementation ledger for binding the canonical Paperclip Run - Canonical protocol actions: **41** - Actions in the Codex semantic catalog: **28** -- Actions advertised by the production Paperclip authority: **13** -- Actions with a real Paperclip service binding: **13** -- Actions audited through the shared Paperclip-server PRP route: **1 full real-service vertical slice; 12 additional bound operations unit-audited** +- Actions advertised by the production Paperclip authority: **14** +- Actions with a real Paperclip service binding: **14** +- Actions audited through the shared Paperclip-server PRP route: **1 full real-service vertical slice; 13 additional bound operations unit-audited** - Existing semantic conformance bridge: five route-backed test operations (`report_progress`, `write_document`, confirmation-only `request_human_input`, `set_dependencies`, and `finish_task`). It is test-only and is not a production binding. The generated action metadata currently says `realServiceBinding: unbound` and `prpBindingStatus: audit_pending` for every action. `realBindingStatus: live_codex` means only that a Codex scenario exercised the mock semantic implementation; it must not be interpreted as a production Paperclip binding. @@ -19,13 +19,13 @@ The generated action metadata currently says `realServiceBinding: unbound` and ` | --- | --- | --- | --- | | Active task reads | `get_task_context`, `get_task_history`, `search_tasks` | issue, comment, project, and assignment services | bound; run/issue/agent/company assignment checked on every call | | Task mutations | `report_progress`, `answer_status_question`, `finish_task`, `block_task`, `request_review`, `set_dependencies`, `create_task` | issue/comment services and native status arbiter | `report_progress` bound with durable run receipt; remaining actions withheld pending mutation/status-arbiter audit | -| Documents and deliverables | `list_documents`, `read_document`, `list_document_revisions`, `write_document`, `register_deliverable` | document and attachment services | three reads bound; mutations withheld pending idempotency audit | +| Documents and deliverables | `list_documents`, `read_document`, `list_document_revisions`, `write_document`, `register_deliverable` | document and attachment services | three reads and `write_document` bound; write uses the real optimistic-revision service and a run-locked durable receipt. Deliverable registration remains withheld | | Human interaction | `request_human_input` | interaction service and continuation scheduler | bound for confirmation, checkbox, questions, suggested tasks, and item verdicts | | Agents | `list_agents`, `get_agent` | agent service with company scope | bound with credential/config redaction and company checks | | Approvals | `list_approvals`, `get_approval`, `get_approval_context`, `request_approval`, `decide_approval`, `comment_on_approval` | approval service | three reads bound; mutations withheld. `decide_approval` is board-user-only in the current product and must not be agent-advertised | | Workspace | `get_workspace_runtime`, `control_workspace_service` | workspace runtime service | unbound | | Scheduling | `schedule_wake` | wakeup service | unbound | -| Discovery | tool search and tool schema lookup | authorized catalog projection | runner-local authorized projection; production authority supplies only the 13 bound definitions | +| Discovery | tool search and tool schema lookup | authorized catalog projection | runner-local authorized projection; production authority supplies only the 14 bound definitions | | Administrative surface | `administer_company`, `export_company`, `list_company_skills`, `sync_company_skills`, `list_projects`, `list_goals`, `list_routines`, `manage_routine`, `list_cases`, `upsert_case`, `inspect_operation_result` | existing company-scoped API/services; individually authorization-gated | protocol-only/scenario mock | | Secrets | `list_secret_metadata`, `read_secret_value` | secrets service; metadata/value claims separated and output redacted | protocol-only/scenario mock | | Escape hatch | `generic_api_request` | no production binding planned until a narrow allowlist is designed | test-only | @@ -60,4 +60,4 @@ an embedded real Paperclip database and active native heartbeat run are routed through the shared server endpoint to Rust runnerd and the fake Codex app-server; Codex calls `get_task_context`, and the result is read from the real run-bound Paperclip authority. The remaining advertised operations still need -their own route-level cases before the roster can call all 13 fully audited. +their own route-level cases before the roster can call all 14 fully audited. diff --git a/packages/paperclip-runner/src/catalog/canonical-operations.ts b/packages/paperclip-runner/src/catalog/canonical-operations.ts index bd6c896d3b0..d9fd84f1646 100644 --- a/packages/paperclip-runner/src/catalog/canonical-operations.ts +++ b/packages/paperclip-runner/src/catalog/canonical-operations.ts @@ -24,6 +24,7 @@ const PRODUCTION_BINDINGS: Readonly { it("advertises only real bindings and reads the bound task", async () => { const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); - expect(authority.definitions()).toHaveLength(13); + expect(authority.definitions()).toHaveLength(14); expect(authority.definitions().map((tool) => tool.name)).toEqual(expect.arrayContaining([ "get_task_context", "get_task_history", "search_tasks", "report_progress", "request_human_input", - "list_documents", "read_document", "list_document_revisions", + "list_documents", "read_document", "list_document_revisions", "write_document", "list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context", ])); await expect(authority.execute({ tool: "get_task_context", callId: "context", arguments: {} })) @@ -109,6 +109,35 @@ describe("PaperclipRunnerToolAuthority", () => { .toHaveLength(1); }); + it("writes a real revisioned document and replays the mutation receipt", async () => { + const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); + const call = { + tool: "write_document", + callId: "write-plan", + arguments: { + idempotencyKey: "write-plan-1", + key: "plan", + title: "Execution plan", + body: "Use the real document service.", + baseRevisionId: null, + changeSummary: "Initial plan", + }, + }; + const first = await authority.execute(call); + const replay = await authority.execute({ ...call, callId: "write-plan-replay" }); + expect(replay).toEqual(first); + expect(first).toMatchObject({ + disposition: "applied", + created: true, + document: { key: "plan", body: "Use the real document service." }, + }); + expect(await db.select().from(documents).where(eq(documents.companyId, companyId))).toHaveLength(1); + await expect(authority.execute({ + ...call, + arguments: { ...call.arguments, body: "Conflicting retry." }, + })).rejects.toThrow("paperclip_runner_tool_idempotency_conflict"); + }); + it("fails closed once the run is no longer active", async () => { await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId)); const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index c94574c426b..d444319aa86 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -12,7 +12,7 @@ import { persistActivity, publishActivity } from "../activity-log.js"; const IMPLEMENTED_OPERATIONS = new Set([ "get_task_context", "get_task_history", "search_tasks", "report_progress", "request_human_input", - "list_documents", "read_document", "list_document_revisions", + "list_documents", "read_document", "list_document_revisions", "write_document", "list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context", ]); @@ -92,6 +92,7 @@ export class PaperclipRunnerToolAuthority { } case "list_document_revisions": return { revisions: await documentService(this.db).listIssueDocumentRevisions(this.binding.issueId, requiredString(input.key)) }; + case "write_document": return this.#writeDocument(input); case "list_agents": return { actors: (await agentService(this.db).list(this.binding.companyId)).map(redactedActor) }; case "get_agent": { @@ -183,6 +184,62 @@ export class PaperclipRunnerToolAuthority { }); } + async #writeDocument(input: Record): Promise { + const idempotencyKey = requiredString(input.idempotencyKey); + return this.#withMutationReceipt("write_document", idempotencyKey, input, async (tx) => { + const write = await documentService(tx).upsertIssueDocument({ + issueId: this.binding.issueId, + key: requiredString(input.key), + title: requiredString(input.title), + format: "markdown", + body: requiredString(input.body), + baseRevisionId: input.baseRevisionId === null ? null : requiredString(input.baseRevisionId), + changeSummary: input.changeSummary === null || input.changeSummary === undefined + ? null + : requiredString(input.changeSummary), + createdByAgentId: this.binding.agentId, + createdByRunId: this.binding.runId, + }); + return { + disposition: "applied", + created: write.created, + document: write.document, + }; + }); + } + + async #withMutationReceipt( + operationId: string, + idempotencyKey: string, + input: Record, + effect: (tx: Db) => Promise, + ): Promise { + return this.db.transaction(async (tx) => { + const [run] = await tx.select().from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, this.binding.runId), eq(heartbeatRuns.companyId, this.binding.companyId))) + .for("update").limit(1); + if (!run || run.status !== "running" || run.agentId !== this.binding.agentId) { + throw new Error("paperclip_runner_tool_binding_not_authorized"); + } + const resultJson = record(run.resultJson); + const receipts = record(resultJson.semanticToolReceipts); + const prior = receipts[idempotencyKey] as ToolReceipt | undefined; + if (prior !== undefined) { + if (prior.operationId !== operationId || canonicalJson(prior.input) !== canonicalJson(input)) { + throw new Error("paperclip_runner_tool_idempotency_conflict"); + } + return prior.result; + } + const result = JSON.parse(JSON.stringify(await effect(tx as unknown as Db))) as unknown; + receipts[idempotencyKey] = { operationId, input, result } satisfies ToolReceipt; + await tx.update(heartbeatRuns).set({ + resultJson: { ...resultJson, semanticToolReceipts: receipts }, + updatedAt: new Date(), + }).where(eq(heartbeatRuns.id, this.binding.runId)); + return result; + }); + } + async #requestHumanInput(input: Record, issue: typeof issues.$inferSelect): Promise { const interactionKind = requiredString(input.interactionKind); const kind = ({