mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(cli): retain committed evidence receipts on output failure (#7906)
This commit is contained in:
1 parent
9b880e0e3e
commit
f9acfdcf49
6 files changed
+269
-103
No files matched your search
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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[] = [];
|
||||
|
||||
Reference in new issue
Block a user