feat(runner): bind revisioned document writes

This commit is contained in:
Dotta committed 2026-08-21 09:35:24 -05:00
1 parent 29305c3e45
commit 057b901240
4 files changed
+97 -10

No files matched your search

@@ -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.
@@ -24,6 +24,7 @@ const PRODUCTION_BINDINGS: Readonly<Record<string, { service: string; evidence:
list_documents: { service: "documentService.listIssueDocuments", evidence: "bound issue document read" },
read_document: { service: "documentService.getIssueDocumentByKey", evidence: "bound issue/key document read" },
list_document_revisions: { service: "documentService.listIssueDocumentRevisions", evidence: "bound issue/key revision read" },
write_document: { service: "documentService.upsertIssueDocument + heartbeatRuns semanticToolReceipts", evidence: "real revision mutation + optimistic concurrency + idempotent replay test" },
list_agents: { service: "agentService.list (redacted projection)", evidence: "company-scoped actor projection" },
get_agent: { service: "agentService.getById (redacted projection)", evidence: "company equality + redacted actor projection" },
list_approvals: { service: "approvalService.list", evidence: "company-scoped approval projection" },
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { agents, companies, createDb, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { agents, companies, createDb, documents, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
@@ -53,11 +53,11 @@ describe("PaperclipRunnerToolAuthority", () => {
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 });
@@ -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<string, unknown>): Promise<unknown> {
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<string, unknown>,
effect: (tx: Db) => Promise<unknown>,
): Promise<unknown> {
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<string, unknown>, issue: typeof issues.$inferSelect): Promise<unknown> {
const interactionKind = requiredString(input.interactionKind);
const kind = ({