From c4242cb85eb5d8f7d6230ebedf162def6feb3717 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:24:59 +0800 Subject: [PATCH] fix(evidence): own standalone command finalization --- src/application/EvidenceBundleCommands.ts | 146 +++++++++-- src/cli/analysisViewCommands.ts | 74 +++--- src/cliEvidenceCommands.ts | 109 ++++---- .../bundleComparisonFailures.test.ts | 248 ++++++++++++++++++ 4 files changed, 458 insertions(+), 119 deletions(-) diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index 81d7a89162..98f20980d0 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -34,24 +34,33 @@ import { readComparisonEvidenceBundles, writeEvidenceBundle, } from "./EvidenceBundleFiles.js"; +import { inspectEvidenceBundle } from "./investigation/InspectEvidenceBundle.js"; +import type { EvidenceRecordFilters } from "../domain/evidenceRecordSummary.js"; import { inspectAnalysisViewValidated } from "./analysisView/AnalysisViewService.js"; /** Validate a standalone bundle and report its complete record/revision counts. */ export const importEvidenceBundleCommand = async ( path: string, + signal?: AbortSignal, ): Promise> => { - const loaded = await readEvidenceBundle( - path, - undefined, - undefined, + return withBundleReadResources( + async (resources) => { + const loaded = await readEvidenceBundle( + path, + signal, + resources, + "import_evidence_bundle", + ); + if (!loaded.ok) return loaded; + return ok({ + imported: loaded.value.records.length, + unknowns_added: loaded.value.unknowns.length, + total: loaded.value.records.length, + }); + }, "import_evidence_bundle", + signal, ); - if (!loaded.ok) return loaded; - return ok({ - imported: loaded.value.records.length, - unknowns_added: loaded.value.unknowns.length, - total: loaded.value.records.length, - }); }; /** Validate a source bundle and atomically export canonical bytes. */ @@ -59,19 +68,37 @@ export const exportEvidenceBundleCommand = async ( sourcePath: string, outputPath: string, overwrite: boolean, + signal?: AbortSignal, ): Promise> => { // The sealed snapshot lets the writer reuse this authentication. - const loaded = await readImmutableEvidenceBundle( - sourcePath, - undefined, - undefined, + const loaded = await withBundleReadResources( + (resources) => + readImmutableEvidenceBundle( + sourcePath, + signal, + resources, + "export_evidence_bundle", + ), "export_evidence_bundle", + signal, ); if (!loaded.ok) return loaded; - return projectWrite( + const written = await persistEvidenceBundleCommand( loaded.value, - await persistEvidenceBundleCommand(loaded.value, outputPath, overwrite), + outputPath, + overwrite, + signal, + "export_evidence_bundle", ); + await dispatchQueuedInterrupts(); + if (written.ok && signal?.aborted) + return err( + new AnalysisCancelledError("export_evidence_bundle", { + cause: signal.reason, + diagnostics: { evidence_bundle: { ...written.value } }, + }), + ); + return projectWrite(loaded.value, written); }; /** Publish complete parent Evidence before delivering it or its linked summary. */ @@ -224,23 +251,32 @@ export const compareEvidenceBundlesCommand = async (input: { readonly leftPath: string; readonly rightPath: string; readonly recordPairs?: readonly BundleRecordPair[]; + readonly signal?: AbortSignal; }): Promise> => { // Sealed snapshots let comparison and its bundle digests reuse this // authentication instead of re-validating every record twice more. - const loaded = await readComparisonEvidenceBundles( - input.leftPath, - input.rightPath, + return withBundleReadResources( + async (resources) => { + const loaded = await readComparisonEvidenceBundles( + input.leftPath, + input.rightPath, + input.signal, + resources, + ); + if (!loaded.ok) return loaded; + const { left, right } = loaded.value; + try { + return ok( + jsonValueSchema.parse(compareBundles(left, right, input.recordPairs)), + ); + } catch (cause: unknown) { + if (cause instanceof AnalysisInputError) return err(cause); + throw cause; + } + }, + "compare_bundles", + input.signal, ); - if (!loaded.ok) return loaded; - const { left, right } = loaded.value; - try { - return ok( - jsonValueSchema.parse(compareBundles(left, right, input.recordPairs)), - ); - } catch (cause: unknown) { - if (cause instanceof AnalysisInputError) return err(cause); - throw cause; - } }; const projectWrite = ( @@ -254,3 +290,55 @@ const projectWrite = ( records: bundle.records.length, }) : err(written.error); + +/** Standalone bundle discovery shares the same read owner as import and comparison. */ +export const inspectEvidenceBundleCommand = async ( + input: { + readonly path: string; + readonly detail: "complete" | "summary"; + readonly filters?: EvidenceRecordFilters | undefined; + }, + signal?: AbortSignal, +) => { + return withBundleReadResources( + async (resources) => { + const loaded = await readEvidenceBundle( + input.path, + signal, + resources, + "get_evidence_bundle", + ); + return loaded.ok ? inspectEvidenceBundle(loaded.value, input) : loaded; + }, + "get_evidence_bundle", + signal, + ); +}; + +/** Final read cleanup retries the exact leases already bound to a failed read. */ +const withBundleReadResources = async ( + read: ( + resources: ArtifactResourceScope, + ) => Promise>, + operation: string, + signal?: AbortSignal, +): Promise> => { + const resources = new ArtifactResourceScope(); + let result: Result; + try { + result = await read(resources); + } finally { + // Read errors own their retained leases and observe resolution or remaining + // failure in place. A retry must not replace the original read failure. + await resources.close().catch(() => undefined); + await dispatchQueuedInterrupts(); + } + return result.ok && signal?.aborted + ? err( + new AnalysisCancelledError(operation, { + cause: signal.reason, + diagnostics: { completed_result: result.value }, + }), + ) + : result; +}; diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index 676b12591a..7ac18af53f 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -3,8 +3,7 @@ import { z } from "incur"; import { inspectAnalysisView } from "../application/analysisView/AnalysisViewService.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ANALYSIS_VIEW_PROVIDER } from "../domain/investigationProviders.js"; -import { readEvidenceBundle } from "../application/EvidenceBundleFiles.js"; -import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js"; +import { inspectEvidenceBundleCommand } from "../application/EvidenceBundleCommands.js"; import { getEvidenceBundleInputSchema, importEvidenceBundleInputSchema, @@ -36,46 +35,41 @@ export const registerAnalysisViewCommands = ( ), }), run: ({ args }) => - logCliCommand(logger, CLI_COMMANDS.inspectEvidenceBundle, async () => { - const input = await parseCliJsonInput( - args.inputJson, - CLI_COMMANDS.inspectEvidenceBundle, - ); - if (!input.ok) return input.error; - const schema = getEvidenceBundleInputSchema.safeExtend({ - path: importEvidenceBundleInputSchema.shape.path, - }); - const parsed = schema.safeParse(input.value); - if (!parsed.success) - return { - error: "Evidence bundle inspection failed", - ...projectAnalysisError( - analysisInputErrorFromIssues( - CLI_COMMANDS.inspectEvidenceBundle, - parsed.error.issues, - input.value, - ), - ), - }; - const loaded = await readEvidenceBundle( - parsed.data.path, - undefined, - undefined, - "get_evidence_bundle", - ); - if (!loaded.ok) - return { - error: "Evidence bundle inspection failed", - ...projectAnalysisError(loaded.error), - }; - const result = inspectEvidenceBundle(loaded.value, parsed.data); - return result.ok - ? result.value - : { + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectEvidenceBundle, async () => { + const input = await parseCliJsonInput( + args.inputJson, + CLI_COMMANDS.inspectEvidenceBundle, + signal, + ); + if (!input.ok) return input.error; + const schema = getEvidenceBundleInputSchema.safeExtend({ + path: importEvidenceBundleInputSchema.shape.path, + }); + const parsed = schema.safeParse(input.value); + if (!parsed.success) + return { error: "Evidence bundle inspection failed", - ...projectAnalysisError(result.error), + ...projectAnalysisError( + analysisInputErrorFromIssues( + CLI_COMMANDS.inspectEvidenceBundle, + parsed.error.issues, + input.value, + ), + ), }; - }), + const result = await inspectEvidenceBundleCommand( + parsed.data, + signal, + ); + return result.ok + ? result.value + : { + error: "Evidence bundle inspection failed", + ...projectAnalysisError(result.error), + }; + }), + ), }); cli.command(CLI_COMMANDS.inspectAnalysisView, { description: diff --git a/src/cliEvidenceCommands.ts b/src/cliEvidenceCommands.ts index 733bb927b2..faf66a0f26 100644 --- a/src/cliEvidenceCommands.ts +++ b/src/cliEvidenceCommands.ts @@ -1,3 +1,4 @@ +import { withCommandCancellation } from "./cli/commandCancellation.js"; import { safeParseJson } from "./domain/safeJson.js"; import { AnalysisInputError } from "./domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js"; @@ -27,10 +28,12 @@ export const registerEvidenceCommands = ( path: z.string().describe("Evidence bundle JSON path"), }), run: ({ args }) => - logCliCommand(logger, "evidence-import", async () => { - const imported = await importEvidenceBundleCommand(args.path); - return imported.ok ? imported.value : cliError(imported.error); - }), + withCommandCancellation((signal) => + logCliCommand(logger, "evidence-import", async () => { + const imported = await importEvidenceBundleCommand(args.path, signal); + return imported.ok ? imported.value : cliError(imported.error); + }), + ), }); cli.command(CLI_COMMANDS.evidenceExport, { description: "Validate and atomically export canonical Evidence JSON", @@ -42,14 +45,17 @@ export const registerEvidenceCommands = ( overwrite: z.boolean().default(false).describe("Replace output file"), }), run: ({ args, options }) => - logCliCommand(logger, "evidence-export", async () => { - const exported = await exportEvidenceBundleCommand( - args.source, - args.output, - options.overwrite, - ); - return exported.ok ? exported.value : cliError(exported.error); - }), + withCommandCancellation((signal) => + logCliCommand(logger, "evidence-export", async () => { + const exported = await exportEvidenceBundleCommand( + args.source, + args.output, + options.overwrite, + signal, + ); + return exported.ok ? exported.value : cliError(exported.error); + }), + ), }); cli.command(CLI_COMMANDS.compare, { description: "Compare two canonical Evidence bundles", @@ -66,44 +72,47 @@ export const registerEvidenceCommands = ( ), }), run: ({ args, options }) => - logCliCommand(logger, "compare", async () => { - const decoded = - options.record_pairs === undefined - ? { ok: true as const, value: [] } - : safeParseJson(options.record_pairs); - if (!decoded.ok) - return cliError( - new AnalysisInputError( - "compare_bundles", - { cause: decoded.cause }, - [ - { - path: ["record_pairs"], - reason: "invalid_format", - expected: "JSON array", - }, - ], - ), - ); - const input = { record_pairs: decoded.value }; - const selected = bundleComparisonInputSchema - .pick({ record_pairs: true }) - .safeParse(input); - if (!selected.success) - return cliError( - analysisInputErrorFromIssues( - "compare_bundles", - selected.error.issues, - input, - ), - ); - const compared = await compareEvidenceBundlesCommand({ - leftPath: args.left, - rightPath: args.right, - recordPairs: selected.data.record_pairs, - }); - return compared.ok ? compared.value : cliError(compared.error); - }), + withCommandCancellation((signal) => + logCliCommand(logger, "compare", async () => { + const decoded = + options.record_pairs === undefined + ? { ok: true as const, value: [] } + : safeParseJson(options.record_pairs); + if (!decoded.ok) + return cliError( + new AnalysisInputError( + "compare_bundles", + { cause: decoded.cause }, + [ + { + path: ["record_pairs"], + reason: "invalid_format", + expected: "JSON array", + }, + ], + ), + ); + const input = { record_pairs: decoded.value }; + const selected = bundleComparisonInputSchema + .pick({ record_pairs: true }) + .safeParse(input); + if (!selected.success) + return cliError( + analysisInputErrorFromIssues( + "compare_bundles", + selected.error.issues, + input, + ), + ); + const compared = await compareEvidenceBundlesCommand({ + leftPath: args.left, + rightPath: args.right, + recordPairs: selected.data.record_pairs, + signal, + }); + return compared.ok ? compared.value : cliError(compared.error); + }), + ), }); }; diff --git a/tests/process-global/bundleComparisonFailures.test.ts b/tests/process-global/bundleComparisonFailures.test.ts index e2ab249fd5..722bc47112 100644 --- a/tests/process-global/bundleComparisonFailures.test.ts +++ b/tests/process-global/bundleComparisonFailures.test.ts @@ -13,8 +13,11 @@ import { writeEvidenceBundle, } from "../../src/application/EvidenceBundleFiles.js"; import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; +import { OwnedStagedOutput } from "../../src/filesystem/OwnedStagedOutput.js"; import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js"; import { RetainedRegularFileCleanupFailure } from "../../src/application/RegularFileRead.js"; +import * as bundleComparison from "../../src/domain/bundleComparison.js"; +import * as bundleSummary from "../../src/domain/evidenceRecordSummary.js"; import { bundleComparisonResultSchema } from "../../src/domain/bundleComparison.js"; import { processEvidence } from "../../src/application/ReconstructionObligationLedger.fixture.js"; import { processCaptureSchema } from "../../src/domain/process/processCapture.js"; @@ -315,3 +318,248 @@ it.each([ ).rejects.toBe(failure); }, ); + +const standaloneBundleArguments = ( + command: string, + path: string, + output: string, +) => + command === "inspect-evidence-bundle" + ? [command, JSON.stringify({ path })] + : command === "compare" + ? [command, path, path] + : command === "evidence-export" + ? [command, path, output] + : [command, path]; + +it.each([ + "evidence-import", + "evidence-export", + "compare", + "inspect-evidence-bundle", +])( + "retries retained read cleanup through the standalone %s owner", + async (command) => { + const directory = await createTestTempDirectory( + "rea-standalone-bundle-cleanup-", + ); + const { path } = await validBundleFile(directory); + const output = join(directory, "output.json"); + const original = OwnedFileHandle.prototype.close; + const attempts = new Map(); + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + const attempt = (attempts.get(this) ?? 0) + 1; + attempts.set(this, attempt); + if (attempt < 3) throw new Error("retryable descriptor close failure"); + return original.call(this); + }); + onTestFinished(async () => { + close.mockRestore(); + for (const owner of attempts.keys()) await owner.close(); + }); + let stdout = ""; + const previousExitCode = process.exitCode; + try { + await createCli({}).serve( + [...standaloneBundleArguments(command, path, output), "--json"], + { + stdout: (part) => { + stdout += part; + }, + exit: () => {}, + }, + ); + expect(attempts.size).toBe(command === "compare" ? 2 : 1); + expect([...attempts.values()]).toEqual( + command === "compare" ? [3, 3] : [3], + ); + expect([...attempts.keys()].every((owner) => owner.handle.fd < 0)).toBe( + true, + ); + const result = JSON.parse(stdout); + // A read which failed to complete its own cleanup remains a failed read; + // final cleanup resolves resource ownership rather than inventing data. + expect(result).toMatchObject({ code: "execution_failure" }); + expect(result.details?.cleanup_incomplete).not.toBe(true); + expect(await readdir(directory)).toEqual(["bundle.json"]); + } finally { + process.exitCode = previousExitCode; + } + }, +); + +it.each([ + "evidence-import", + "evidence-export", + "compare", + "inspect-evidence-bundle", +])("handles SIGINT during the standalone %s input close", async (command) => { + const directory = await createTestTempDirectory( + "rea-standalone-bundle-cancel-", + ); + const { path } = await validBundleFile(directory); + const output = join(directory, "output.json"); + const original = OwnedFileHandle.prototype.close; + const owners = new Set(); + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.add(this); + await original.call(this); + process.emit("SIGINT"); + }); + onTestFinished(() => close.mockRestore()); + const before = process.listenerCount("SIGINT"); + const previousExitCode = process.exitCode; + let stdout = ""; + try { + process.exitCode = undefined; + await createCli({}).serve( + [...standaloneBundleArguments(command, path, output), "--json"], + { + stdout: (part) => { + stdout += part; + }, + exit: () => {}, + }, + ); + expect(JSON.parse(stdout)).toMatchObject({ code: "cancelled" }); + expect(process.exitCode).toBe(130); + expect(process.listenerCount("SIGINT")).toBe(before); + expect([...owners].every((owner) => owner.handle.fd < 0)).toBe(true); + expect(await readdir(directory)).toEqual(["bundle.json"]); + } finally { + process.exitCode = previousExitCode; + } +}); + +it.each(["compare", "inspect-evidence-bundle"])( + "retains completed %s output when SIGINT is queued during projection", + async (command) => { + const directory = await createTestTempDirectory( + "rea-standalone-bundle-projection-", + ); + const { path, bundle } = await validBundleFile(directory); + const queueInterrupt = () => { + setImmediate(() => process.emit("SIGINT")); + }; + const compare = bundleComparison.compareBundles; + const summarize = bundleSummary.summarizeEvidenceBundle; + const hook = + command === "compare" + ? vi + .spyOn(bundleComparison, "compareBundles") + .mockImplementation((...args) => { + const result = compare(...args); + queueInterrupt(); + return result; + }) + : vi + .spyOn(bundleSummary, "summarizeEvidenceBundle") + .mockImplementation((...args) => { + const result = summarize(...args); + queueInterrupt(); + return result; + }); + onTestFinished(() => hook.mockRestore()); + const previousExitCode = process.exitCode; + const listeners = process.listenerCount("SIGINT"); + let stdout = ""; + try { + process.exitCode = undefined; + await createCli({}).serve( + [ + ...(command === "compare" + ? [command, path, path] + : [command, JSON.stringify({ path, detail: "summary" })]), + "--json", + ], + { + stdout: (part) => { + stdout += part; + }, + exit: () => {}, + }, + ); + const expected = + command === "compare" ? compare(bundle, bundle) : summarize(bundle); + expect(JSON.parse(stdout)).toMatchObject({ + code: "cancelled", + details: { diagnostics: { completed_result: expected } }, + }); + expect(hook).toHaveBeenCalledOnce(); + expect(process.exitCode).toBe(130); + expect(process.listenerCount("SIGINT")).toBe(listeners); + } finally { + process.exitCode = previousExitCode; + } + }, +); + +it.each(["before-commit", "after-commit", "cleanup-retry"])( + "preserves standalone export publication facts on SIGINT at %s", + async (phase) => { + const directory = await createTestTempDirectory( + "rea-standalone-export-publication-", + ); + const { path, bundle } = await validBundleFile(directory); + const output = join(directory, "output.json"); + const method = + phase === "before-commit" ? "assertReadyForPublication" : "close"; + const original = OwnedStagedOutput.prototype[method]; + let attempts = 0; + const hook = vi + .spyOn(OwnedStagedOutput.prototype, method) + .mockImplementation(async function ( + this: OwnedStagedOutput, + ...args: Parameters + ) { + attempts += 1; + if (phase === "cleanup-retry" && attempts === 1) + throw new Error("retry staged cleanup"); + await Reflect.apply(original, this, args); + if (phase === "before-commit") process.emit("SIGINT"); + else setImmediate(() => process.emit("SIGINT")); + }); + onTestFinished(() => hook.mockRestore()); + const previousExitCode = process.exitCode; + const listeners = process.listenerCount("SIGINT"); + let stdout = ""; + try { + process.exitCode = undefined; + await createCli({}).serve(["evidence-export", path, output, "--json"], { + stdout: (part) => { + stdout += part; + }, + exit: () => {}, + }); + const result = JSON.parse(stdout); + expect(result).toMatchObject({ code: "cancelled" }); + expect(process.exitCode).toBe(130); + expect(process.listenerCount("SIGINT")).toBe(listeners); + if (phase === "before-commit") { + expect(await readdir(directory)).toEqual(["bundle.json"]); + expect(result).not.toHaveProperty( + "details.diagnostics.evidence_bundle", + ); + } else { + const bytes = await stat(output); + expect(result.details.diagnostics.evidence_bundle).toEqual({ + path: output, + bytes: bytes.size, + }); + const loaded = await readEvidenceBundle(output); + expect(loaded).toEqual({ ok: true, value: bundle }); + expect(await readdir(directory)).toEqual([ + "bundle.json", + "output.json", + ]); + } + expect(attempts).toBe(phase === "cleanup-retry" ? 2 : 1); + } finally { + process.exitCode = previousExitCode; + } + }, +);