diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index 718ed7d9df..3ec09dbd64 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -1,3 +1,4 @@ +import { recordCliEvidencePublication } from "./cliEvidencePublication.js"; import { analysisEvidenceSources } from "../application/AnalysisEvidenceSources.js"; import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js"; import { z } from "incur"; @@ -215,19 +216,17 @@ export const publishCliAnalysisEvidence = async ( signal, }); if (!published.ok) return projectFailure(published.error); + const receipt = { + ...(input.sensitiveValues?.some( + (literal) => + literal !== "" && published.value.evidenceBundle.path.includes(literal), + ) + ? {} + : { path: published.value.evidenceBundle.path }), + bytes: published.value.evidenceBundle.bytes, + }; + recordCliEvidencePublication(receipt); return detail === "summary" - ? { - summary: published.value.delivered, - evidence_bundle: { - ...(input.sensitiveValues?.some( - (literal) => - literal !== "" && - published.value.evidenceBundle.path.includes(literal), - ) - ? {} - : { path: published.value.evidenceBundle.path }), - bytes: published.value.evidenceBundle.bytes, - }, - } + ? { summary: published.value.delivered, evidence_bundle: receipt } : published.value.delivered; }; diff --git a/src/cli/cliEvidencePublication.ts b/src/cli/cliEvidencePublication.ts new file mode 100644 index 0000000000..62dfcf065f --- /dev/null +++ b/src/cli/cliEvidencePublication.ts @@ -0,0 +1,38 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; + +export interface PublishedEvidenceReceipt { + readonly path?: string; + readonly bytes: number; +} + +interface PublicationContext { + receipt?: PublishedEvidenceReceipt; +} +const publication = new AsyncLocalStorage(); + +/** Keep publication state in the invocation that owns delivery and formatting. */ +export const withCliEvidencePublication = (run: () => Value): Value => + publication.run({}, run); + +export const recordCliEvidencePublication = ( + receipt: PublishedEvidenceReceipt, +): void => { + const context = publication.getStore(); + if (context !== undefined) context.receipt = receipt; +}; + +/** Snapshot a receipt for output recovery after the invocation context ends. */ +export const cliEvidencePublicationReceipt = (): + | PublishedEvidenceReceipt + | undefined => publication.getStore()?.receipt; + +/** Preserve an already committed file when subsequent output selection fails. */ +export const retainCliPublicationOnError = ( + error: ErrorType, + receipt = cliEvidencePublicationReceipt(), +): ErrorType => { + return receipt === undefined + ? error + : error.retainDiagnostics({ evidence_bundle: { ...receipt } }); +}; diff --git a/src/cli/cliResultOutput.ts b/src/cli/cliResultOutput.ts index b9085cf232..f23ba115bb 100644 --- a/src/cli/cliResultOutput.ts +++ b/src/cli/cliResultOutput.ts @@ -30,6 +30,11 @@ import { prepareDelimitedOutput, delimitedOutputInputError, } from "./cliOutputFormatter.js"; +import { + cliEvidencePublicationReceipt, + retainCliPublicationOnError, + withCliEvidencePublication, +} from "./cliEvidencePublication.js"; import { jsonLinesErrorRecord } from "./jsonLines.js"; import { writeCliJsonLines } from "./jsonLinesOutput.js"; import { @@ -92,42 +97,43 @@ const wrapCommand = ( inheritedPolicy?: "all" | "agent-only", ): void => { const run = command.run; - command.run = (context) => { - const started = performance.now(); - const result: unknown = run(context); - // Generator results and Incur's sentinel responses retain their own protocol. - if ( - typeof result === "object" && - result !== null && - Symbol.asyncIterator in result - ) { - output.delegateStreaming(); - return result; - } - return Promise.resolve(result).then(async (value: unknown) => { + command.run = (context) => + withCliEvidencePublication(() => { + const started = performance.now(); + const result: unknown = run(context); + // Generator results and Incur's sentinel responses retain their own protocol. if ( - typeof value === "object" && - value !== null && - Symbol.for("incur.sentinel") in value - ) - return value; - if ( - !(await output.write(value, { - command: name, - format: context.format, - renderOutput: context.agent || inheritedPolicy !== "agent-only", - duration: `${Math.round(performance.now() - started)}ms`, - })) - ) - return value; - if (output.error !== undefined) - return context.error({ - code: output.error.code, - message: output.error.message, - }); - return undefined; + typeof result === "object" && + result !== null && + Symbol.asyncIterator in result + ) { + output.delegateStreaming(); + return result; + } + return Promise.resolve(result).then(async (value: unknown) => { + if ( + typeof value === "object" && + value !== null && + Symbol.for("incur.sentinel") in value + ) + return value; + if ( + !(await output.write(value, { + command: name, + format: context.format, + renderOutput: context.agent || inheritedPolicy !== "agent-only", + duration: `${Math.round(performance.now() - started)}ms`, + })) + ) + return value; + if (output.error !== undefined) + return context.error({ + code: output.error.code, + message: output.error.message, + }); + return undefined; + }); }); - }; }; /** Preserve Incur output controls and stream JSON while framing failures. */ @@ -154,6 +160,7 @@ export const createCliResultOutput = ( let completed: | { readonly metadata: CliResultOutputMetadata; + readonly receipt: ReturnType; readonly originalFailure: ReturnType< typeof cliOperationFailureIdentity >; @@ -171,24 +178,27 @@ export const createCliResultOutput = ( return false; const { originalFailure } = completed; error = projectAnalysisError( - new AnalysisResourceConstraintError( - completed.metadata.command, - "memory", - `The ${format} result could not fit in a runtime string. The original result was not written.`, - { - format, - formatted_characters: null, - max_string_characters: constants.MAX_STRING_LENGTH, - ...(originalFailure === undefined - ? {} - : { original_failure: originalFailure }), - }, - { - cause, - retryable: false, - remediationAction: - "Use --format json to stream the complete result, or narrow it with --filter-output. Token limits are applied after formatting and cannot avoid this constraint.", - }, + retainCliPublicationOnError( + new AnalysisResourceConstraintError( + completed.metadata.command, + "memory", + `The ${format} result could not fit in a runtime string. The original result was not written.`, + { + format, + formatted_characters: null, + max_string_characters: constants.MAX_STRING_LENGTH, + ...(originalFailure === undefined + ? {} + : { original_failure: originalFailure }), + }, + { + cause, + retryable: false, + remediationAction: + "Use --format json to stream the complete result, or narrow it with --filter-output. Token limits are applied after formatting and cannot avoid this constraint.", + }, + ), + completed.receipt, ), ); const document = options.fullOutput @@ -333,6 +343,7 @@ export const createCliResultOutput = ( ): Promise { completed = { metadata, + receipt: cliEvidencePublicationReceipt(), originalFailure: cliOperationFailureIdentity(value), }; const filterPaths = options.filterOutput @@ -400,7 +411,9 @@ export const createCliResultOutput = ( }) : selection; if (!prepared.ok) { - error = projectAnalysisError(prepared.error); + error = projectAnalysisError( + retainCliPublicationOnError(prepared.error), + ); handled = true; try { await writeJsonOutput( @@ -431,20 +444,22 @@ export const createCliResultOutput = ( : applyCliOutputFilter(value, filterPaths); if (!selection.ok) { error = projectAnalysisError( - new AnalysisInputError( - metadata.command, - { - userMessage: selection.error, - remediationAction: - "Select array indices with slices, or request array properties in a separate --filter-output invocation.", - }, - [ + retainCliPublicationOnError( + new AnalysisInputError( + metadata.command, { - path: ["filter-output"], - reason: "invalid_value", - message: selection.error, + userMessage: selection.error, + remediationAction: + "Select array indices with slices, or request array properties in a separate --filter-output invocation.", }, - ], + [ + { + path: ["filter-output"], + reason: "invalid_value", + message: selection.error, + }, + ], + ), ), ); const document = options.fullOutput @@ -578,18 +593,20 @@ export const createCliResultOutput = ( } } error = projectAnalysisError( - new AnalysisResourceConstraintError( - metadata.command, - "memory", - "Token counting requires a formatted string larger than the runtime permits.", - { - formatted_characters: characters, - max_string_characters: constants.MAX_STRING_LENGTH, - }, - { - remediationAction: - "Remove --token-count to stream the complete JSON result, or narrow it with --filter-output.", - }, + retainCliPublicationOnError( + new AnalysisResourceConstraintError( + metadata.command, + "memory", + "Token counting requires a formatted string larger than the runtime permits.", + { + formatted_characters: characters, + max_string_characters: constants.MAX_STRING_LENGTH, + }, + { + remediationAction: + "Remove --token-count to stream the complete JSON result, or narrow it with --filter-output.", + }, + ), ), ); } diff --git a/src/cli/jsonLinesOutput.ts b/src/cli/jsonLinesOutput.ts index 150ccaa277..9626b9125f 100644 --- a/src/cli/jsonLinesOutput.ts +++ b/src/cli/jsonLinesOutput.ts @@ -20,6 +20,7 @@ import { writeOutputParts, writeOutputText, } from "./jsonOutput.js"; +import { retainCliPublicationOnError } from "./cliEvidencePublication.js"; interface CliJsonLinesInput { readonly source: unknown; @@ -66,7 +67,7 @@ export const writeCliJsonLines = async ( }) : undefined; if (plan?.ok === false) { - error = projectAnalysisError(plan.error); + error = projectAnalysisError(retainCliPublicationOnError(plan.error)); failureRecord = jsonLinesErrorRecord( options.fullOutput ? { ok: false, error, meta } : error, ); @@ -81,18 +82,20 @@ export const writeCliJsonLines = async ( for (const part of jsonLineParts(record)) characters += part.length; if (characters > constants.MAX_STRING_LENGTH) { error = projectAnalysisError( - new AnalysisResourceConstraintError( - options.command, - "memory", - "A JSON Lines record exceeds the token estimator's runtime string limit.", - { - formatted_characters: characters, - max_string_characters: constants.MAX_STRING_LENGTH, - }, - { - remediationAction: - "Remove --token-count to stream the records, or select smaller fields with --filter-output.", - }, + retainCliPublicationOnError( + new AnalysisResourceConstraintError( + options.command, + "memory", + "A JSON Lines record exceeds the token estimator's runtime string limit.", + { + formatted_characters: characters, + max_string_characters: constants.MAX_STRING_LENGTH, + }, + { + remediationAction: + "Remove --token-count to stream the records, or select smaller fields with --filter-output.", + }, + ), ), ); break; diff --git a/tests/acceptance/tools/nativeFunctionMatching.test.ts b/tests/acceptance/tools/nativeFunctionMatching.test.ts index 20405c4746..794f31a410 100644 --- a/tests/acceptance/tools/nativeFunctionMatching.test.ts +++ b/tests/acceptance/tools/nativeFunctionMatching.test.ts @@ -80,6 +80,61 @@ const createMatchingFixture = async () => { importedPath, }; }; + +cliTest( + "keeps the committed Evidence receipt when JSON Lines refuses the result", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-matching-jsonl-receipt-"); + const request = join(root, "request.json"); + const left = nativeMatchingInventory("1", [ + { address: "0x10", value: "main" }, + ]); + const right = nativeMatchingInventory("2", [ + { address: "0x20", value: "main" }, + ]); + for (const detail of ["summary", "complete"] as const) { + await writeFile(request, JSON.stringify({ left, right, detail })); + const output = join(root, `${detail}.json`); + const result = await cli.run({ + arguments: [ + "match-native-functions", + request, + "--evidence_output", + output, + "--format", + "jsonl", + ], + }); + expect(result.exitCode).toBe(1); + const receipt = { + path: output, + bytes: (await readFile(output)).byteLength, + }; + expect(JSON.parse(result.stdout)).toMatchObject({ + type: "error", + result: { + code: "invalid_request", + category: "invalid_input", + details: { diagnostics: { evidence_bundle: receipt } }, + }, + }); + const retry = await cli.run({ + arguments: [ + "match-native-functions", + request, + "--evidence_output", + output, + "--json", + ], + }); + expect(retry.exitCode).toBe(1); + expect(retry.json).toMatchObject({ details: { reason: "exists" } }); + expect( + parseEvidenceBundle(JSON.parse(await readFile(output, "utf8"))).records, + ).toHaveLength(3); + } + }, +); const verifyPortableCli = async ( cli: TestCli, fixture: Awaited>, diff --git a/tests/boundary/cli/formattingRecovery.test.ts b/tests/boundary/cli/formattingRecovery.test.ts index dc4f6ad4f8..8b8c4a81c5 100644 --- a/tests/boundary/cli/formattingRecovery.test.ts +++ b/tests/boundary/cli/formattingRecovery.test.ts @@ -15,9 +15,63 @@ import { } from "../../../src/cliLogging.js"; import { ArtifactOperationError } from "../../../src/domain/artifactOperationError.js"; import { silentLogger } from "../../../src/logger.js"; +import { + cliEvidencePublicationReceipt, + recordCliEvidencePublication, + withCliEvidencePublication, +} from "../../../src/cli/cliEvidencePublication.js"; const metadata = { command: "fixture", duration: "1ms", format: "toon" }; +it("keeps receipts isolated across concurrent invocations", async () => { + const results = await Promise.all( + ["first", "second"].map((path) => + withCliEvidencePublication(async () => { + recordCliEvidencePublication({ path, bytes: 1 }); + await Promise.resolve(); + return cliEvidencePublicationReceipt(); + }), + ), + ); + expect(results).toEqual([ + { path: "first", bytes: 1 }, + { path: "second", bytes: 1 }, + ]); + expect(cliEvidencePublicationReceipt()).toBeUndefined(); +}); + +it("retains a publication receipt when formatting fails after the invocation context ends", async () => { + const chunks: Buffer[] = []; + const output = createCliResultOutput( + parseCliOutputArguments([]), + new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }), + ); + if (output === undefined) throw new Error("Missing output owner"); + await withCliEvidencePublication(async () => { + recordCliEvidencePublication({ path: "/tmp/result.json", bytes: 123 }); + expect(await output.write({ result: "completed" }, metadata)).toBe(false); + }); + expect( + await output.recoverFormattingFailure( + new RangeError("Invalid string length"), + ), + ).toBe(true); + expect(output.error).toMatchObject({ + code: "resource_constraint", + details: { + diagnostics: { + evidence_bundle: { path: "/tmp/result.json", bytes: 123 }, + }, + }, + }); + expect(Buffer.concat(chunks).toString("utf8")).toContain("/tmp/result.json"); +}); + describe("CLI formatting stage admission", () => { it("preserves native CTA sentinel output", async () => { const replies: string[] = [];