mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(runner): bind revisioned document writes
This commit is contained in:
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 = ({
|
||||
|
||||
Reference in new issue
Block a user