From d685037c6e745d49a0bdb337b63866f942d4d677 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 03:00:37 -0700 Subject: [PATCH] fix(workboard): avoid duplicate proof entries on completion (#111324) * fix(workboard): resolve duplicate completion proof * fix(workboard): import proof cap from constants * fix(workboard): correlate completion proof by id * fix(workboard): reuse terminal completion proof * fix(workboard): retain correlated proof under budget --- docs/cli/workboard.md | 4 + docs/plugins/workboard.md | 10 + extensions/workboard/src/dispatcher.test.ts | 1 + extensions/workboard/src/dispatcher.ts | 1 + extensions/workboard/src/store-core.ts | 22 +- extensions/workboard/src/store-enrichment.ts | 42 ++-- extensions/workboard/src/store-inputs.ts | 1 + extensions/workboard/src/store-normalizers.ts | 81 ++++++- extensions/workboard/src/store-workflow.ts | 16 +- extensions/workboard/src/store.test.ts | 200 ++++++++++++++++++ extensions/workboard/src/tools.test.ts | 14 +- extensions/workboard/src/tools.ts | 21 +- 12 files changed, 377 insertions(+), 36 deletions(-) diff --git a/docs/cli/workboard.md b/docs/cli/workboard.md index 9a51670507c..1da976cd998 100644 --- a/docs/cli/workboard.md +++ b/docs/cli/workboard.md @@ -83,6 +83,10 @@ openclaw workboard show 7f4a2c10 --json Text output prints the compact card line and notes. JSON output returns the full card record, including execution metadata, attempts, comments, links, proof, artifacts, worker logs, protocol state, diagnostics, and automation metadata. +Proof statuses in JSON are worker-reported outcomes. `passed` records the worker's +self-assessment of the attached command or check; it is not an independent verification +result. + ## `move` ```bash diff --git a/docs/plugins/workboard.md b/docs/plugins/workboard.md index c293e136709..f93f54426c8 100644 --- a/docs/plugins/workboard.md +++ b/docs/plugins/workboard.md @@ -161,6 +161,16 @@ rule as linked sessions (see [Session lifecycle sync](#session-lifecycle-sync)). | `workboard_move` | Move a card to another status; claimed cards require the caller's agent claim scope. | | `workboard_dispatch` | Nudge dependency promotion or stale-claim cleanup without launching workers; worker launch uses Gateway or slash-command dispatch. | +Proof statuses are worker-reported outcomes, not independent verification. A `passed` +entry means the worker reports that its command or check succeeded; consumers that need +an independent quality gate should inspect the attached command, URL, or artifact and +run their own verifier. `workboard_proof` returns the new record's `proofId`. When +`workboard_complete` reports that same proof's terminal status, pass `proofId` so the +pending record is resolved in place without losing its identity or timestamp. A proof that +already has the same terminal status is reused unchanged. Completion proof without +`proofId` remains append-only, so a later retry cannot rewrite older history merely because +its command or note is identical. + Claimed cards reject agent-tool mutations from other agents unless the caller holds the claim token returned by `workboard_claim`. Every card returned by an agent tool or Gateway RPC call redacts `metadata.claim.token` to `[redacted]` diff --git a/extensions/workboard/src/dispatcher.test.ts b/extensions/workboard/src/dispatcher.test.ts index 996ae0cfcaf..cb642a039d6 100644 --- a/extensions/workboard/src/dispatcher.test.ts +++ b/extensions/workboard/src/dispatcher.test.ts @@ -824,6 +824,7 @@ describe("dispatchAndStartWorkboardCards", () => { }); expect(run.mock.calls[0]?.[0]?.message).toContain("Claim token:"); expect(run.mock.calls[0]?.[0]?.message).toContain("workboard_complete with the card id"); + expect(run.mock.calls[0]?.[0]?.message).toContain("returned proofId"); expect(run.mock.calls[0]?.[0]?.message).not.toContain("ownerId and token"); await expect(store.get(first.id)).resolves.toMatchObject({ status: "running", diff --git a/extensions/workboard/src/dispatcher.ts b/extensions/workboard/src/dispatcher.ts index 26ea8124ec1..57fb2335343 100644 --- a/extensions/workboard/src/dispatcher.ts +++ b/extensions/workboard/src/dispatcher.ts @@ -203,6 +203,7 @@ function buildWorkerPrompt(params: { "", "Heartbeat with workboard_heartbeat using the card id and token while working.", "When done, call workboard_complete with the card id, token, summary, and proof.", + "If you called workboard_proof separately, pass its returned proofId to workboard_complete.", "If blocked, call workboard_block with the card id, token, and reason.", "", params.context, diff --git a/extensions/workboard/src/store-core.ts b/extensions/workboard/src/store-core.ts index 15b25459441..76b97af69f7 100644 --- a/extensions/workboard/src/store-core.ts +++ b/extensions/workboard/src/store-core.ts @@ -122,13 +122,14 @@ export class WorkboardCoreStore { protected async updateMetadata( id: string, mutate: (existing: WorkboardCard) => WorkboardMetadata, + options: { preserveProofId?: string } = {}, ): Promise { return await this.enqueueMutation(async () => { const existing = await this.get(id); if (!existing) { throw new Error(`card not found: ${id}`); } - return await this.updateCard(id, { metadata: mutate(existing) }); + return await this.updateCard(id, { metadata: mutate(existing) }, options); }); } @@ -461,7 +462,11 @@ export class WorkboardCoreStore { protected async updateCard( id: string, patch: WorkboardCardPatch, - options: { allowMetadataDependencyLinks?: boolean; enforceStatusHolds?: boolean } = {}, + options: { + allowMetadataDependencyLinks?: boolean; + enforceStatusHolds?: boolean; + preserveProofId?: string; + } = {}, ): Promise { const existing = await this.get(id); if (!existing) { @@ -519,6 +524,7 @@ export class WorkboardCoreStore { : normalizeExecution(effectivePatch.execution); let metadata = normalizeMetadata(effectivePatch.metadata, existing.metadata, { allowDependencyLinks: options.allowMetadataDependencyLinks !== false, + preserveProofId: options.preserveProofId, }); if (status !== existing.status && !hasFreshLifecycleStatusSource) { // Status patches often spread existing metadata. Only a newly supplied @@ -543,10 +549,13 @@ export class WorkboardCoreStore { } } if (Object.keys(automationPatch).length > 0) { - metadata = trimMetadataToBudget({ - ...metadata, - automation: normalizeAutomationPatch(automationPatch, metadata.automation), - }); + metadata = trimMetadataToBudget( + { + ...metadata, + automation: normalizeAutomationPatch(automationPatch, metadata.automation), + }, + options, + ); } const next = removeUndefinedCardFields({ ...existing, @@ -595,6 +604,7 @@ export class WorkboardCoreStore { }); next.metadata = trimMetadataToBudget( syncExecutionAttemptMetadata(next.metadata ?? {}, execution, now), + options, ); next.events = appendEvent(next, updateEvent(existing, next), now); if (options.enforceStatusHolds && effectivePatch.status !== undefined) { diff --git a/extensions/workboard/src/store-enrichment.ts b/extensions/workboard/src/store-enrichment.ts index 39b38df30d2..136ada0ff2e 100644 --- a/extensions/workboard/src/store-enrichment.ts +++ b/extensions/workboard/src/store-enrichment.ts @@ -45,14 +45,18 @@ export class WorkboardEnrichmentStore extends WorkboardCoreStore { ): Promise { const now = Date.now(); const proof = normalizeProofInput(input, now); - return await this.updateMetadata(id, (existing) => { - assertCanMutateClaimedCard(existing, scope); - const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]); - return { - ...metadata, - proof: [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF), - }; - }); + return await this.updateMetadata( + id, + (existing) => { + assertCanMutateClaimedCard(existing, scope); + const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]); + return { + ...metadata, + proof: [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF), + }; + }, + { preserveProofId: proof.id }, + ); } async addProofWithArtifact( @@ -67,15 +71,19 @@ export class WorkboardEnrichmentStore extends WorkboardCoreStore { if (!artifact) { throw new Error("artifact url or path is required."); } - return await this.updateMetadata(id, (existing) => { - assertCanMutateClaimedCard(existing, scope); - const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]); - return { - ...metadata, - proof: [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF), - artifacts: [...(metadata.artifacts ?? []), artifact].slice(-MAX_CARD_ARTIFACTS), - }; - }); + return await this.updateMetadata( + id, + (existing) => { + assertCanMutateClaimedCard(existing, scope); + const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]); + return { + ...metadata, + proof: [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF), + artifacts: [...(metadata.artifacts ?? []), artifact].slice(-MAX_CARD_ARTIFACTS), + }; + }, + { preserveProofId: proof.id }, + ); } async addArtifact( diff --git a/extensions/workboard/src/store-inputs.ts b/extensions/workboard/src/store-inputs.ts index 297501b1d4b..03fc263bb0a 100644 --- a/extensions/workboard/src/store-inputs.ts +++ b/extensions/workboard/src/store-inputs.ts @@ -110,6 +110,7 @@ export type WorkboardCompleteInput = { token?: unknown; summary?: unknown; proof?: unknown; + proofId?: unknown; artifacts?: unknown; createdCardIds?: unknown; }; diff --git a/extensions/workboard/src/store-normalizers.ts b/extensions/workboard/src/store-normalizers.ts index e909de3eade..1fcd4c5faca 100644 --- a/extensions/workboard/src/store-normalizers.ts +++ b/extensions/workboard/src/store-normalizers.ts @@ -983,13 +983,51 @@ export function normalizeProofInput(input: WorkboardProofInput, now: number): Wo }; } +function completionProofConflicts(existing: WorkboardProof, completion: WorkboardProof): boolean { + return (["label", "command", "url", "note"] as const).some( + (field) => completion[field] !== undefined && completion[field] !== existing[field], + ); +} + +export function appendCompletionProof( + existing: readonly WorkboardProof[] | undefined, + proof: WorkboardProof, + proofId?: string, +): WorkboardProof[] { + const entries = [...(existing ?? [])]; + if (!proofId) { + return [...entries, proof].slice(-MAX_CARD_PROOF); + } + const index = entries.findIndex((entry) => entry.id === proofId); + const pending = index >= 0 ? entries[index] : undefined; + if (!pending) { + throw new Error(`proof not found: ${proofId}`); + } + if (proof.status === "unknown") { + throw new Error("completion proof status must be passed, failed, or skipped."); + } + if (completionProofConflicts(pending, proof)) { + throw new Error(`completion proof does not match pending proof: ${proofId}`); + } + if (pending.status !== "unknown") { + if (pending.status !== proof.status) { + throw new Error(`completion proof status does not match existing proof: ${proofId}`); + } + return entries.slice(-MAX_CARD_PROOF); + } + // A proof id is the durable correlation boundary between a separately recorded check and its + // completion. Preserve the original evidence identity and timestamp while resolving its status. + entries[index] = { ...pending, status: proof.status }; + return entries.slice(-MAX_CARD_PROOF); +} + export function normalizeMetadata( value: unknown, fallback: WorkboardMetadata = {}, - options: { allowDependencyLinks?: boolean } = {}, + options: { allowDependencyLinks?: boolean; preserveProofId?: string } = {}, ): WorkboardMetadata { if (!value || typeof value !== "object" || Array.isArray(value)) { - return trimMetadataToBudget(fallback); + return trimMetadataToBudget(fallback, options); } const record = value as Record; const stale = @@ -1017,7 +1055,7 @@ export function normalizeMetadata( ]; })() : links.slice(-MAX_CARD_LINKS); - return trimMetadataToBudget({ + const normalized = { attempts: Array.isArray(record.attempts) ? record.attempts .map(normalizeAttempt) @@ -1102,7 +1140,8 @@ export function normalizeMetadata( typeof record.failureCount === "number" && Number.isFinite(record.failureCount) ? Math.max(0, Math.trunc(record.failureCount)) : fallback.failureCount, - }); + }; + return trimMetadataToBudget(normalized, options); } export function normalizeExecution(value: unknown): WorkboardExecution | undefined { @@ -1260,6 +1299,21 @@ function dropFirst(items: readonly T[] | undefined): T[] | undefined { return next.length ? next : undefined; } +function dropFirstProofExcept( + items: readonly WorkboardProof[] | undefined, + preserveProofId: string | undefined, +): WorkboardProof[] | undefined { + if (!items?.length) { + return undefined; + } + const index = preserveProofId ? items.findIndex((proof) => proof.id !== preserveProofId) : 0; + if (index < 0) { + return items.slice(); + } + const next = items.filter((_, itemIndex) => itemIndex !== index); + return next.length ? next : undefined; +} + function dropFirstNonDependencyLink( items: readonly WorkboardLink[] | undefined, ): WorkboardLink[] | undefined { @@ -1289,7 +1343,10 @@ export function appendLinkPreservingDependencies( return next.filter((_, index) => index !== dropIndex); } -export function trimMetadataToBudget(metadata: WorkboardMetadata): WorkboardMetadata { +export function trimMetadataToBudget( + metadata: WorkboardMetadata, + options: { preserveProofId?: string } = {}, +): WorkboardMetadata { let next = removeUndefinedMetadataFields(metadata); while (metadataByteSize(next) > MAX_CARD_METADATA_BYTES) { const currentSize = metadataByteSize(next); @@ -1302,8 +1359,13 @@ export function trimMetadataToBudget(metadata: WorkboardMetadata): WorkboardMeta ...next, notifications: dropFirst(next.notifications), }); - } else if (next.proof?.length) { - next = removeUndefinedMetadataFields({ ...next, proof: dropFirst(next.proof) }); + } else if ( + next.proof?.some((proof) => !options.preserveProofId || proof.id !== options.preserveProofId) + ) { + next = removeUndefinedMetadataFields({ + ...next, + proof: dropFirstProofExcept(next.proof, options.preserveProofId), + }); } else if (next.artifacts?.length) { next = removeUndefinedMetadataFields({ ...next, artifacts: dropFirst(next.artifacts) }); } else if (next.attachments?.length) { @@ -1322,8 +1384,13 @@ export function trimMetadataToBudget(metadata: WorkboardMetadata): WorkboardMeta } } else if (next.comments?.length) { next = removeUndefinedMetadataFields({ ...next, comments: dropFirst(next.comments) }); + } else if (options.preserveProofId) { + throw new Error(`card metadata cannot retain proof: ${options.preserveProofId}`); } if (metadataByteSize(next) >= currentSize) { + if (options.preserveProofId) { + throw new Error(`card metadata cannot retain proof: ${options.preserveProofId}`); + } break; } } diff --git a/extensions/workboard/src/store-workflow.ts b/extensions/workboard/src/store-workflow.ts index 01b3092459a..030f6ae665e 100644 --- a/extensions/workboard/src/store-workflow.ts +++ b/extensions/workboard/src/store-workflow.ts @@ -26,7 +26,6 @@ import { MAX_CARD_ARTIFACTS, MAX_CARD_COMMENTS, MAX_CARD_NOTIFICATIONS, - MAX_CARD_PROOF, secondsToDurationMs, } from "./store-constants.js"; import type { @@ -44,6 +43,7 @@ import type { WorkboardSpecifyInput, } from "./store-inputs.js"; import { + appendCompletionProof, clearDiagnostics, deriveChildIdempotencyKey, normalizeArtifact, @@ -248,6 +248,13 @@ export class WorkboardWorkflowStore extends WorkboardPromoteStore { input.proof && typeof input.proof === "object" && !Array.isArray(input.proof) ? (input.proof as WorkboardProofInput) : undefined; + const proofId = normalizeBoundedString(input.proofId, undefined, 120, "proof id"); + if (input.proofId !== undefined && !proofId) { + throw new Error("proofId must be a non-empty string."); + } + if (proofId && !proofInput) { + throw new Error("proof is required when proofId is provided."); + } const proof = proofInput ? normalizeProofInput(proofInput, now) : undefined; const artifacts = Array.isArray(input.artifacts) ? input.artifacts @@ -293,7 +300,7 @@ export class WorkboardWorkflowStore extends WorkboardPromoteStore { { id: randomUUID(), body: summary, createdAt: now }, ].slice(-MAX_CARD_COMMENTS) : metadata.comments, - proof: proof ? [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF) : metadata.proof, + proof: proof ? appendCompletionProof(metadata.proof, proof, proofId) : metadata.proof, artifacts: artifacts.length ? [...(metadata.artifacts ?? []), ...artifacts].slice(-MAX_CARD_ARTIFACTS) : metadata.artifacts, @@ -302,7 +309,10 @@ export class WorkboardWorkflowStore extends WorkboardPromoteStore { ), }, }, - { enforceStatusHolds: true }, + { + enforceStatusHolds: true, + ...(proof ? { preserveProofId: proofId ?? proof.id } : {}), + }, ); } diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts index 2fa8615fc59..e0cb5abba54 100644 --- a/extensions/workboard/src/store.test.ts +++ b/extensions/workboard/src/store.test.ts @@ -1010,6 +1010,206 @@ describe("WorkboardStore", () => { expect(restored.events?.at(-1)).toMatchObject({ kind: "unarchived" }); }); + it("resolves matching unknown proof on completion without duplicating it", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(1_000); + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ title: "Resolve worker proof", status: "running" }); + const claimed = await store.claim(card.id, { ownerId: "main", token: "token-1" }); + const proofInput = { + label: "Haiku syllable check", + command: "review poem", + note: "Checked each line.", + }; + const pending = await store.addProof(claimed.card.id, proofInput, { + ownerId: "main", + token: "token-1", + }); + + vi.setSystemTime(6_000); + const completed = await store.complete(claimed.card.id, { + ownerId: "main", + token: "token-1", + summary: "Poem complete.", + proofId: pending.metadata?.proof?.[0]?.id, + proof: { ...proofInput, status: "passed" }, + }); + + expect(completed.metadata?.proof).toEqual([ + { + ...pending.metadata?.proof?.[0], + status: "passed", + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("retains the correlated proof when metadata budget trimming is required", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ + title: "Keep proof under metadata pressure", + status: "running", + metadata: { + artifacts: Array.from({ length: 12 }, (_, index) => ({ + id: `artifact-${index}`, + createdAt: index + 1, + url: `https://example.com/${index}/${"x".repeat(1900)}`, + })), + }, + }); + const claimed = await store.claim(card.id, { ownerId: "main", token: "token-1" }); + const artifactCountBefore = claimed.card.metadata?.artifacts?.length ?? 0; + expect(artifactCountBefore).toBeGreaterThan(5); + + const proofInput = { + command: "pnpm test extensions/workboard", + note: "y".repeat(1800), + }; + const pending = await store.addProof(card.id, proofInput, { + ownerId: "main", + token: "token-1", + }); + const pendingProof = pending.metadata?.proof?.at(-1); + expect(pendingProof).toMatchObject({ status: "unknown", command: proofInput.command }); + expect(pending.metadata?.artifacts?.length ?? 0).toBeLessThan(artifactCountBefore); + expect(Buffer.byteLength(JSON.stringify(pending.metadata), "utf8")).toBeLessThanOrEqual( + 24 * 1024, + ); + + const completed = await store.complete(card.id, { + ownerId: "main", + token: "token-1", + summary: "Proof survived metadata trimming.", + proofId: pendingProof?.id, + proof: { ...proofInput, status: "passed" }, + }); + + expect(completed.metadata?.proof).toEqual([{ ...pendingProof, status: "passed" }]); + expect(Buffer.byteLength(JSON.stringify(completed.metadata), "utf8")).toBeLessThanOrEqual( + 24 * 1024, + ); + }); + + it("keeps identical completion proof append-only without an explicit proof id", async () => { + const store = new WorkboardStore(createMemoryStore()); + const proofInput = { command: "pnpm test extensions/workboard", note: "94 tests" }; + const card = await store.create({ + title: "Preserve historical proof", + metadata: { + proof: [{ id: "proof-unknown", status: "unknown", createdAt: 1_000, ...proofInput }], + }, + }); + + const failed = await store.complete(card.id, { + summary: "A later run failed.", + proof: { ...proofInput, status: "failed" }, + }); + + expect(failed.metadata?.proof?.map((entry) => [entry.id, entry.status])).toEqual([ + ["proof-unknown", "unknown"], + [expect.any(String), "failed"], + ]); + }); + + it("resolves only the explicitly correlated proof across identical retries", async () => { + const store = new WorkboardStore(createMemoryStore()); + const proofInput = { command: "review poem", note: "Checked each line." }; + const card = await store.create({ + title: "Keep unresolved proof history", + metadata: { + proof: [ + { id: "proof-older", status: "unknown", createdAt: 1_000, ...proofInput }, + { id: "proof-latest", status: "unknown", createdAt: 2_000, ...proofInput }, + ], + }, + }); + + const completed = await store.complete(card.id, { + summary: "Latest check passed.", + proofId: "proof-latest", + proof: { ...proofInput, status: "passed" }, + }); + + expect(completed.metadata?.proof?.map((entry) => [entry.id, entry.status])).toEqual([ + ["proof-older", "unknown"], + ["proof-latest", "passed"], + ]); + }); + + it("reuses an explicitly correlated terminal proof with the same status", async () => { + const store = new WorkboardStore(createMemoryStore()); + const proof = { + id: "proof-passed", + status: "passed" as const, + createdAt: 1_000, + command: "pnpm test extensions/workboard", + }; + const card = await store.create({ + title: "Reuse terminal proof", + metadata: { proof: [proof] }, + }); + + const completed = await store.complete(card.id, { + summary: "Already passed.", + proofId: proof.id, + proof: { status: "passed", command: proof.command }, + }); + + expect(completed.metadata?.proof).toEqual([proof]); + }); + + it("rejects an explicitly correlated terminal proof with a different status", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ + title: "Reject terminal status rewrite", + metadata: { + proof: [{ id: "proof-passed", status: "passed", createdAt: 1_000 }], + }, + }); + + await expect( + store.complete(card.id, { + proofId: "proof-passed", + proof: { status: "failed" }, + }), + ).rejects.toThrow("completion proof status does not match existing proof: proof-passed"); + await expect(store.get(card.id)).resolves.toMatchObject({ + status: "todo", + metadata: { proof: [{ id: "proof-passed", status: "passed" }] }, + }); + }); + + it("rejects a completion proof id when its evidence does not match", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ + title: "Reject mismatched proof", + metadata: { + proof: [ + { + id: "proof-pending", + status: "unknown", + createdAt: 1_000, + command: "pnpm test extensions/workboard", + }, + ], + }, + }); + + await expect( + store.complete(card.id, { + proofId: "proof-pending", + proof: { status: "passed", command: "pnpm test extensions/other" }, + }), + ).rejects.toThrow("completion proof does not match pending proof: proof-pending"); + await expect(store.get(card.id)).resolves.toMatchObject({ + status: "todo", + metadata: { proof: [{ id: "proof-pending", status: "unknown" }] }, + }); + }); + it("stores attachments in the plugin kv namespace and adds worker context", async () => { const attachments = createMemoryStore(); const store = new WorkboardStore(createMemoryStore(), { attachments }); diff --git a/extensions/workboard/src/tools.test.ts b/extensions/workboard/src/tools.test.ts index d8009fd34ef..176500f9712 100644 --- a/extensions/workboard/src/tools.test.ts +++ b/extensions/workboard/src/tools.test.ts @@ -409,16 +409,28 @@ describe("workboard tools", () => { await tools.get("workboard_claim")?.execute("call-3", { id: parent.id }), ); const token = claimed.token as string; + const pendingProof = readPayload( + await tools.get("workboard_proof")?.execute("call-proof", { + id: parent.id, + token, + command: "pnpm test extensions/workboard", + }), + ); + expect(pendingProof.proofId).toEqual(expect.any(String)); const completed = readPayload( await tools.get("workboard_complete")?.execute("call-4", { id: parent.id, token, summary: "Done.", createdCardIds: [child.id], + proofId: pendingProof.proofId, proof: { status: "passed", command: "pnpm test extensions/workboard" }, }), ); - expect(completed.card).toMatchObject({ status: "done" }); + expect(completed.card).toMatchObject({ + status: "done", + metadata: { proof: [{ id: pendingProof.proofId, status: "passed" }] }, + }); const dispatch = readPayload(await tools.get("workboard_dispatch")?.execute("call-5", {})); expect(dispatch.promoted).toEqual([expect.objectContaining({ id: child.id, status: "ready" })]); diff --git a/extensions/workboard/src/tools.ts b/extensions/workboard/src/tools.ts index ee7ce759a92..1fae64b8a1a 100644 --- a/extensions/workboard/src/tools.ts +++ b/extensions/workboard/src/tools.ts @@ -168,6 +168,17 @@ function redactedRawCardResult(card: WorkboardCard) { return jsonResult(redactClaimToken(card)); } +function redactedProofResult(card: WorkboardCard) { + const proofId = card.metadata?.proof?.at(-1)?.id; + if (!proofId) { + throw new Error("proof was not retained in card metadata."); + } + return jsonResult({ + card: redactClaimToken(card), + proofId, + }); +} + const CardIdSchema = Type.Object( { id: cardIdField(), @@ -439,7 +450,7 @@ export function createWorkboardTools(params: { name: "workboard_proof", label: "Workboard Proof", description: - "Attach proof or artifact metadata to a Workboard card after running tests, checks, or producing screenshots/logs.", + "Attach proof or artifact metadata to a Workboard card after running tests, checks, or producing screenshots/logs. Returns proofId; pass it to workboard_complete when that call reports the terminal status for this proof.", parameters: Type.Object( { id: cardIdField(), @@ -474,7 +485,7 @@ export function createWorkboardTools(params: { scope, ) : await store.addProof(id, record, scope); - return redactedCardResult(card); + return redactedProofResult(card); }, }, { @@ -487,6 +498,12 @@ export function createWorkboardTools(params: { id: cardIdField(), token: claimTokenField(), summary: Type.Optional(Type.String({ description: "Completion summary." })), + proofId: Type.Optional( + Type.String({ + description: + "Proof id returned by workboard_proof when resolving that pending proof.", + }), + ), proof: Type.Optional( Type.Object( {