mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
fix(cli): verify partial Evidence closure before publication
This commit is contained in:
1 parent
3345019fe1
commit
dc7594e85f
6 files changed
+148
-97
No files matched your search
@@ -1419,9 +1419,12 @@ to `--evidence_output` under the same new-file and atomic rules, then keeps its
|
||||
error code, message and remediation when cleanup is confirmed. With `--detail summary`,
|
||||
`details.partial_observation` holds the summary view of the partial Evidence
|
||||
instead of the complete record, and `details.diagnostics.evidence_bundle`
|
||||
reports the written path and byte count. If publication is refused before
|
||||
commit, for example because the file already exists, the complete partial
|
||||
Evidence stays inline and `details.diagnostics.evidence_bundle_error` gives the
|
||||
reports the written path and byte count. Linked source records must all be
|
||||
available before publication; missing sources leave the destination unchanged
|
||||
and are reported in `details.source_closure`. A provider cleanup failure can
|
||||
still publish its captured Evidence without clearing the cleanup error or its
|
||||
resource ownership. If publication is refused before commit, for example because
|
||||
the file already exists, the complete partial Evidence stays inline and `details.diagnostics.evidence_bundle_error` gives the
|
||||
refusal's code, message and a limitation. A refusal after commit keeps the
|
||||
receipt in `details.diagnostics.evidence_bundle` and the delivered view: when
|
||||
staging cleanup stays incomplete, the code becomes `cleanup_incomplete`, the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveInvestigationEvidenceClosure } from "./investigation/InvestigationEvidence.js";
|
||||
import { analysisEvidenceSources } from "./AnalysisEvidenceSources.js";
|
||||
import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
@@ -180,6 +181,17 @@ export const publishEvidenceDelivery = async (input: {
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
}): Promise<Result<PublishedEvidenceDelivery, AnalysisError>> => {
|
||||
const { signal, operation } = input;
|
||||
// Partial analysis can hold only some of its linked sources. Both delivery
|
||||
// adapters must establish closure before committing a complete source bundle.
|
||||
const sources = new Map(
|
||||
input.bundle.records.map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
const closure = resolveInvestigationEvidenceClosure(
|
||||
{ evidenceForAnalysis: (id) => sources.get(id) },
|
||||
[input.complete.evidence_id],
|
||||
);
|
||||
if (!closure.ok)
|
||||
return err(closure.error.retainPartialObservation(input.complete));
|
||||
const written = await persistEvidenceBundleCommand(
|
||||
input.bundle,
|
||||
input.outputPath,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
@@ -164,7 +165,7 @@ it("reports the CLI last page and its observed node budget separately", async ()
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the completed trace's source closure when final close fails", async () => {
|
||||
it("publishes the completed trace with its sources when final close fails", async () => {
|
||||
const run = await serveTraceCli(
|
||||
valueTraceDiamond({ sessionSubject: true }).port,
|
||||
async () =>
|
||||
@@ -183,16 +184,16 @@ it("preserves the completed trace's source closure when final close fails", asyn
|
||||
const trace = parseEvidence(failed.details.partial_observation);
|
||||
expect(trace.evidence_links.length).toBeGreaterThan(0);
|
||||
expect(failed.details).toMatchObject({
|
||||
source_closure: {
|
||||
publication: "not_published",
|
||||
complete: true,
|
||||
omitted_count: 0,
|
||||
records: expect.arrayContaining([
|
||||
expect.objectContaining({ evidence_id: trace.evidence_links[0] }),
|
||||
]),
|
||||
},
|
||||
resources: ["owned session"],
|
||||
diagnostics: { evidence_bundle: { path: destination } },
|
||||
});
|
||||
await expect(readFile(destination)).rejects.toThrow();
|
||||
expect(failed.details).not.toHaveProperty("source_closure");
|
||||
const bundle = parseEvidenceBundle(
|
||||
JSON.parse(await readFile(destination, "utf8")),
|
||||
);
|
||||
expect(bundle.records.map((record) => record.evidence_id)).toEqual(
|
||||
expect.arrayContaining([trace.evidence_id, ...trace.evidence_links]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
|
||||
@@ -2245,7 +2246,7 @@ it("retains completed function sources when cancellation arrives as execution re
|
||||
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("retains function sources when completed parent admission fails", async () => {
|
||||
it("publishes captured function sources when completed parent admission fails", async () => {
|
||||
const fixture = await createSnapshotReplayFixture(
|
||||
"rea-direct-parent-admission-",
|
||||
);
|
||||
@@ -2276,17 +2277,21 @@ it("retains function sources when completed parent admission fails", async () =>
|
||||
detail: "complete",
|
||||
evidenceOutput: outputPath,
|
||||
});
|
||||
const failure = output as {
|
||||
details: {
|
||||
partial_observation: Evidence;
|
||||
source_closure: { records: Evidence[]; complete: boolean };
|
||||
};
|
||||
};
|
||||
expect(failure.details.source_closure.complete).toBe(true);
|
||||
expect(
|
||||
failure.details.source_closure.records.map((record) => record.evidence_id),
|
||||
).toEqual(failure.details.partial_observation.evidence_links);
|
||||
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(output).toMatchObject({
|
||||
code: "evidence_integrity_mismatch",
|
||||
details: { diagnostics: { evidence_bundle: { path: outputPath } } },
|
||||
});
|
||||
expect(output).not.toHaveProperty("details.source_closure");
|
||||
const bundle = parseEvidenceBundle(
|
||||
JSON.parse(await readFile(outputPath, "utf8")),
|
||||
);
|
||||
const parent = bundle.records.find(
|
||||
(record) => record.operation === "analyze_function",
|
||||
);
|
||||
expect(parent).toBeDefined();
|
||||
expect(bundle.records.map((record) => record.evidence_id).sort()).toEqual(
|
||||
[parent!.evidence_id, ...parent!.evidence_links].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs decompile export against the provider after loading a pseudocode snapshot", async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../../src/domain/evidence.js";
|
||||
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { jsonByteLength } from "../../../src/domain/jsonSerialization.js";
|
||||
import type { JsonValue } from "../../../src/domain/jsonValue.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
@@ -208,7 +209,7 @@ const wideSource = (links: readonly string[]) =>
|
||||
evidenceLinks: links,
|
||||
});
|
||||
|
||||
it.each(["final-close cancellation", "refused publication"] as const)(
|
||||
it.each(["final-close cancellation", "missing sources"] as const)(
|
||||
"keeps the original failure when a captured source has a data-sized link set: %s",
|
||||
async (failure) => {
|
||||
const root = await createTestTempDirectory("rea-closure-wide-");
|
||||
@@ -218,17 +219,18 @@ it.each(["final-close cancellation", "refused publication"] as const)(
|
||||
const wide = wideSource(links);
|
||||
const observation = observed([wide.evidence_id]);
|
||||
const requested = delivery(root, [wide]);
|
||||
if (failure === "refused publication")
|
||||
await writeFile(requested.evidenceOutput, "original");
|
||||
const output = await publishCliAnalysisEvidence(
|
||||
failure === "refused publication"
|
||||
failure === "missing sources"
|
||||
? ok(observation)
|
||||
: err(cancelledWith(observation)),
|
||||
requested,
|
||||
);
|
||||
expect(output).toMatchObject(
|
||||
failure === "refused publication"
|
||||
? { details: { reason: "exists" } }
|
||||
failure === "missing sources"
|
||||
? {
|
||||
code: "evidence_integrity_mismatch",
|
||||
details: { reason: "missing" },
|
||||
}
|
||||
: { code: "cancelled" },
|
||||
);
|
||||
const closure = closureOf(output);
|
||||
@@ -593,7 +595,7 @@ it.each(["interrupt", "cleanup-failure", "interrupt-cleanup-failure"] as const)(
|
||||
);
|
||||
|
||||
it.each(["interrupt", "cleanup-failure"] as const)(
|
||||
"retains captured source closure when direct finalization fails: %s",
|
||||
"preserves captured sources when direct finalization fails: %s",
|
||||
async (scenario) => {
|
||||
const workflow = await composedWorkflowFixture();
|
||||
const controller = new AbortController();
|
||||
@@ -635,6 +637,23 @@ it.each(["interrupt", "cleanup-failure"] as const)(
|
||||
failure.details.partial_observation,
|
||||
).evidence_links;
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
if (scenario === "cleanup-failure") {
|
||||
const path = join(root, "evidence.json");
|
||||
expect(output).toMatchObject({
|
||||
details: { diagnostics: { evidence_bundle: { path } } },
|
||||
});
|
||||
expect(output).not.toHaveProperty("details.source_closure");
|
||||
const bundle = parseEvidenceBundle(
|
||||
JSON.parse(await readFile(path, "utf8")),
|
||||
);
|
||||
expect(bundle.records.map((record) => record.evidence_id).sort()).toEqual(
|
||||
[
|
||||
parseEvidence(failure.details.partial_observation).evidence_id,
|
||||
...links,
|
||||
].sort(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
expect(closureOf(output)).toMatchObject({
|
||||
publication: "not_published",
|
||||
complete: true,
|
||||
|
||||
@@ -58,10 +58,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
type FinalClose =
|
||||
| "control"
|
||||
| "interrupt"
|
||||
| "cleanup-failure"
|
||||
| "interrupt-cleanup-failure";
|
||||
"control" | "interrupt" | "cleanup-failure" | "interrupt-cleanup-failure";
|
||||
|
||||
const fixture = async (dependencyText = "Synthetic dependency") => {
|
||||
const root = await createTestTempDirectory("rea-source-closure-");
|
||||
@@ -185,8 +182,7 @@ const DATA_SIZED_LINKS = 200_000;
|
||||
const closureOf = (stdout: string) => {
|
||||
const failure = analysisErrorProjectionSchema.parse(JSON.parse(stdout));
|
||||
const closure = failure.details?.source_closure as unknown as
|
||||
| UndeliveredSourceClosure
|
||||
| undefined;
|
||||
UndeliveredSourceClosure | undefined;
|
||||
if (closure === undefined) throw new Error(`No source closure: ${stdout}`);
|
||||
return { failure, closure };
|
||||
};
|
||||
@@ -199,7 +195,7 @@ it.each([
|
||||
["cleanup-failure", "cleanup_incomplete", 1],
|
||||
["interrupt-cleanup-failure", "cleanup_incomplete", 130],
|
||||
] as const)(
|
||||
"keeps captured linked sources through final close: %s",
|
||||
"preserves captured linked sources through final close: %s",
|
||||
async (finalClose, code, exitCode) => {
|
||||
const input = await fixture();
|
||||
const { stdout, exitCode: observedExit } = await runMatching(
|
||||
@@ -207,8 +203,30 @@ it.each([
|
||||
finalClose,
|
||||
);
|
||||
expect(observedExit).toBe(exitCode);
|
||||
const { failure, closure } = closureOf(stdout);
|
||||
const failure = analysisErrorProjectionSchema.parse(JSON.parse(stdout));
|
||||
expect(failure.code).toBe(code);
|
||||
if (finalClose === "cleanup-failure") {
|
||||
expect(failure.details).toMatchObject({
|
||||
resources: ["owned-root"],
|
||||
diagnostics: { evidence_bundle: { path: input.output } },
|
||||
});
|
||||
expect(failure.details).not.toHaveProperty("source_closure");
|
||||
const saved = parseEvidenceBundle(
|
||||
JSON.parse(await readFile(input.output, "utf8")),
|
||||
);
|
||||
const observation = parseEvidence(failure.details?.partial_observation);
|
||||
expect(saved.records.map((record) => record.evidence_id).sort()).toEqual(
|
||||
[
|
||||
observation.evidence_id,
|
||||
input.left.evidence_id,
|
||||
input.right.evidence_id,
|
||||
input.dependency.evidence_id,
|
||||
].sort(),
|
||||
);
|
||||
expect(host.listeners.size).toBe(0);
|
||||
return;
|
||||
}
|
||||
const { closure } = closureOf(stdout);
|
||||
const observation = parseEvidence(failure.details?.partial_observation);
|
||||
expect(observation.evidence_links).toEqual(
|
||||
[input.left.evidence_id, input.right.evidence_id].sort(),
|
||||
@@ -247,63 +265,56 @@ it("keeps the fault on the final close when another scope closes first", async (
|
||||
expect(closure.complete).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["interrupt", "cancelled"],
|
||||
["cleanup-failure", "cleanup_incomplete"],
|
||||
] as const)(
|
||||
"keeps the typed failure for a captured source with a data-sized link set: %s",
|
||||
async (finalClose, code) => {
|
||||
// Repeated links are valid Evidence, and only one distinct record backs them.
|
||||
const ancestor = createEvidence(undefined, nativeMatchingProvider, {
|
||||
operation: "fixture_ancestor",
|
||||
parameters: {},
|
||||
result: { source: "Synthetic ancestor" },
|
||||
});
|
||||
const wide = createEvidence(undefined, nativeMatchingProvider, {
|
||||
operation: "fixture_dependency",
|
||||
parameters: {},
|
||||
result: { source: "Synthetic wide dependency" },
|
||||
evidenceLinks: Array.from(
|
||||
{ length: DATA_SIZED_LINKS },
|
||||
() => ancestor.evidence_id,
|
||||
),
|
||||
});
|
||||
const input = await fixture();
|
||||
const left = nativeMatchingInventory(
|
||||
"1",
|
||||
[{ address: "0x10", value: "main" }],
|
||||
[wide.evidence_id],
|
||||
);
|
||||
await writeFile(
|
||||
input.sources,
|
||||
JSON.stringify(createEvidenceBundle([ancestor, wide])),
|
||||
);
|
||||
await writeFile(
|
||||
input.request,
|
||||
JSON.stringify({ left, right: input.right, detail: "complete" }),
|
||||
);
|
||||
const { stdout } = await runMatching(input, finalClose, {
|
||||
environment: {
|
||||
REA_NATIVE_MATCHING_REPRESENTATION_BYTES: String(1024 ** 3),
|
||||
},
|
||||
});
|
||||
const { failure, closure } = closureOf(stdout);
|
||||
expect(failure.code).toBe(code);
|
||||
expect(parseEvidence(failure.details?.partial_observation).operation).toBe(
|
||||
"match_native_functions",
|
||||
);
|
||||
// The closure is deduplicated: each record is examined and reported once.
|
||||
expect(recordIds(closure)).toEqual(
|
||||
[ancestor.evidence_id, left.evidence_id, input.right.evidence_id].sort(),
|
||||
);
|
||||
expect(closure).toMatchObject({
|
||||
complete: false,
|
||||
omitted_count: 1,
|
||||
omitted: [{ evidence_id: wide.evidence_id, reason: "not_inlined" }],
|
||||
});
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
it("keeps cancellation typed for a captured source with a data-sized link set", async () => {
|
||||
// Repeated links are valid Evidence, and only one distinct record backs them.
|
||||
const ancestor = createEvidence(undefined, nativeMatchingProvider, {
|
||||
operation: "fixture_ancestor",
|
||||
parameters: {},
|
||||
result: { source: "Synthetic ancestor" },
|
||||
});
|
||||
const wide = createEvidence(undefined, nativeMatchingProvider, {
|
||||
operation: "fixture_dependency",
|
||||
parameters: {},
|
||||
result: { source: "Synthetic wide dependency" },
|
||||
evidenceLinks: Array.from(
|
||||
{ length: DATA_SIZED_LINKS },
|
||||
() => ancestor.evidence_id,
|
||||
),
|
||||
});
|
||||
const input = await fixture();
|
||||
const left = nativeMatchingInventory(
|
||||
"1",
|
||||
[{ address: "0x10", value: "main" }],
|
||||
[wide.evidence_id],
|
||||
);
|
||||
await writeFile(
|
||||
input.sources,
|
||||
JSON.stringify(createEvidenceBundle([ancestor, wide])),
|
||||
);
|
||||
await writeFile(
|
||||
input.request,
|
||||
JSON.stringify({ left, right: input.right, detail: "complete" }),
|
||||
);
|
||||
const { stdout } = await runMatching(input, "interrupt", {
|
||||
environment: {
|
||||
REA_NATIVE_MATCHING_REPRESENTATION_BYTES: String(1024 ** 3),
|
||||
},
|
||||
});
|
||||
const { failure, closure } = closureOf(stdout);
|
||||
expect(failure.code).toBe("cancelled");
|
||||
expect(parseEvidence(failure.details?.partial_observation).operation).toBe(
|
||||
"match_native_functions",
|
||||
);
|
||||
// The closure is deduplicated: each record is examined and reported once.
|
||||
expect(recordIds(closure)).toEqual(
|
||||
[ancestor.evidence_id, left.evidence_id, input.right.evidence_id].sort(),
|
||||
);
|
||||
expect(closure).toMatchObject({
|
||||
complete: false,
|
||||
omitted_count: 1,
|
||||
omitted: [{ evidence_id: wide.evidence_id, reason: "not_inlined" }],
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("hands off the same closure for summary delivery and permits a clean retry", async () => {
|
||||
const input = await fixture();
|
||||
|
||||
Reference in new issue
Block a user