fix(cli): retain committed evidence receipts on output failure (#7906)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 05:11:02 +08:00
1 parent 9b880e0e3e
commit f9acfdcf49
6 files changed
+269 -103

No files matched your search

+12 -13
View File
@@ -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;
};
+38
View File
@@ -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<PublicationContext>();
/** Keep publication state in the invocation that owns delivery and formatting. */
export const withCliEvidencePublication = <Value>(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 = <ErrorType extends AnalysisError>(
error: ErrorType,
receipt = cliEvidencePublicationReceipt(),
): ErrorType => {
return receipt === undefined
? error
: error.retainDiagnostics({ evidence_bundle: { ...receipt } });
};
+94 -77
View File
@@ -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<typeof cliEvidencePublicationReceipt>;
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<boolean> {
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.",
},
),
),
);
}
+16 -13
View File
@@ -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;
@@ -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<ReturnType<typeof createMatchingFixture>>,
@@ -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[] = [];