From 441ba94086e899f8e8bb159fea2bd8cee617a019 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:22:21 +0800 Subject: [PATCH] fix: preserve source closure during snapshot replay (#8870) * fix: preserve source closure during snapshot replay * fix: revalidate restored snapshot sources lazily * fix: bound and share source-closure checks --- src/application/DirectAnalysis.ts | 28 ++- .../binary/AnalysisSnapshotCache.test.ts | 151 ++++++++++++- .../binary/AnalysisSnapshotCache.ts | 88 +++++--- .../binary/AnalysisSnapshotReplayBinding.ts | 9 +- .../binary/BinarySessionRecords.ts | 6 +- .../InvestigationEvidence.test.ts | 52 ++++- .../investigation/InvestigationEvidence.ts | 76 +++++-- .../directAnalysisSnapshotReplay.test.ts | 105 ++++++++- .../mcp/workflowSnapshotReplayMcp.test.ts | 209 +++++++++++++++++- 9 files changed, 661 insertions(+), 63 deletions(-) diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index eb486c3f17..08325bcf97 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -26,6 +26,7 @@ import { type AnalysisResultWithSources, } from "./investigation/recordEvidenceSources.js"; import { resolveInvestigationEvidenceClosure } from "./investigation/InvestigationEvidence.js"; +import { createInvestigationEvidenceClosureChecker } from "./investigation/InvestigationEvidence.js"; import type { Evidence } from "../domain/evidence.js"; import type { NativeToolName } from "../contracts/native/nativeToolContracts.js"; import type { EnhancedToolName } from "../contracts/enhancedInputs.js"; @@ -500,15 +501,26 @@ const runAnalysis = async ( evidenceProfile, }); if (cached !== undefined) { - const evidence = withEvidenceCallerInputPath(cached, path); - const sources = captureSources( - evidence, - snapshot.evidence_bundle.records, + const records = new Map( + snapshot.evidence_bundle.records.map((record) => [ + record.evidence_id, + record, + ]), ); - if (!sources.ok) - return err(sources.error.retainPartialObservation(evidence)); - options.onEvidenceBundle?.(snapshot.evidence_bundle); - return ok(evidence); + const checkClosure = createInvestigationEvidenceClosureChecker({ + evidenceForAnalysis: (id) => records.get(id), + }); + if (checkClosure(cached.evidence_links)) { + const evidence = withEvidenceCallerInputPath(cached, path); + const sources = captureSources( + evidence, + snapshot.evidence_bundle.records, + ); + if (!sources.ok) + return err(sources.error.retainPartialObservation(evidence)); + options.onEvidenceBundle?.(snapshot.evidence_bundle); + return ok(evidence); + } } } } diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index 40e823c817..2858552cdf 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -7,7 +7,8 @@ import { ANALYSIS_SNAPSHOT_PROVIDER, ANALYSIS_SNAPSHOT_TARGET, } from "../../domain/analysisSnapshot.fixture.js"; -import { createEvidence } from "../../domain/evidence.js"; +import { createEvidence, immutableEvidence } from "../../domain/evidence.js"; +import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js"; import { createEvidenceBundle, evidenceBundleForTarget, @@ -1304,6 +1305,154 @@ it("declines source-bearing snapshot entries while replaying omitted and empty s expect(cache.entries()).toHaveLength(2); }); +it("replays source-bearing entries only when their complete retained closure exists", () => { + const target = ANALYSIS_SNAPSHOT_TARGET; + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const leaf = immutableEvidence( + createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "leaf", + parameters: {}, + result: "leaf observation", + }), + ); + const source = immutableEvidence( + createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "source", + parameters: {}, + result: "source observation", + evidenceLinks: [leaf.evidence_id], + }), + ); + const parameters = { address: "0x1000", document: "fixture" }; + const execution = createAnalysisExecution( + "parent observation", + ANALYSIS_SNAPSHOT_PROVIDER, + { + subject: target, + analysisProfile: profile, + sourceEvidence: [source], + }, + ); + const parent = immutableEvidence( + createAnalysisExecutionEvidence( + "procedure_info", + parameters, + execution, + "observed", + ), + ); + const complete = new Map( + [parent, source, leaf].map((record) => [record.evidence_id, record]), + ); + const input = { + target, + profile, + operation: "procedure_info" as const, + parameters, + execution: { ...execution, evidenceId: parent.evidence_id }, + }; + const missingLeaf = new AnalysisSnapshotCache(); + missingLeaf.record(input, (id) => + id === leaf.evidence_id ? undefined : complete.get(id), + ); + expect( + missingLeaf.lookup(target, profile, "procedure_info", parameters), + ).toBeUndefined(); + const cache = new AnalysisSnapshotCache(); + cache.record(input, (id) => complete.get(id)); + expect( + cache + .lookup(target, profile, "procedure_info", parameters) + ?.sourceEvidence?.map((record) => record.evidence_id), + ).toEqual([source.evidence_id, parent.evidence_id]); + if (source.subject === null) throw new Error("Expected source subject"); + const ledger = new EvidenceLedger(); + for (const record of [parent, source, leaf]) + expect(ledger.record(record).ok).toBe(true); + const movedSource = { + ...source, + subject: { + ...source.subject, + local_path: "/relocated/source", + name: "source", + }, + }; + const replacement = ledger.import(createEvidenceBundle([movedSource])); + if (!replacement.ok) throw replacement.error; + cache.refreshEvidenceMetadata(replacement.value.replacements); + expect( + cache.lookup(target, profile, "procedure_info", parameters) + ?.sourceEvidence?.[0]?.subject?.local_path, + ).toBe("/relocated/source"); + const exported = cache.export(target, profile, ledger.export()); + if (!exported.ok) throw exported.error; + const imported = new AnalysisSnapshotCache(); + imported.stage(exported.value); + expect( + imported + .lookup(target, profile, "procedure_info", parameters) + ?.sourceEvidence?.map((record) => record.evidence_id), + ).toEqual([source.evidence_id, parent.evidence_id]); + expect( + imported.lookup(target, profile, "procedure_info", parameters) + ?.sourceEvidence?.[0]?.subject?.local_path, + ).toBe("/relocated/source"); + const pruned = parseAnalysisSnapshot({ + ...exported.value, + evidence_bundle: createEvidenceBundle([parent, movedSource]), + }); + const incomplete = new AnalysisSnapshotCache(); + incomplete.stage(pruned); + const partial = new Map( + [parent, movedSource].map((record) => [record.evidence_id, record]), + ); + expect( + incomplete.lookup(target, profile, "procedure_info", parameters, (id) => + partial.get(id), + ), + ).toBeUndefined(); + partial.set(leaf.evidence_id, leaf); + expect( + incomplete + .lookup(target, profile, "procedure_info", parameters, (id) => + partial.get(id), + ) + ?.sourceEvidence?.map((record) => record.evidence_id), + ).toEqual([source.evidence_id, parent.evidence_id]); + + const otherSource = immutableEvidence( + createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "other_source", + parameters: {}, + result: "different source", + }), + ); + const otherParent = immutableEvidence( + createAnalysisExecutionEvidence( + "procedure_info", + parameters, + { ...execution, sourceEvidence: [otherSource] }, + "observed", + ), + ); + const records = [parent, movedSource, leaf, otherParent, otherSource]; + const ambiguous = new AnalysisSnapshotCache(); + ambiguous.stage( + parseAnalysisSnapshot({ + ...exported.value, + evidence_bundle: createEvidenceBundle(records), + }), + ); + const allRecords = new Map( + records.map((record) => [record.evidence_id, record]), + ); + expect( + ambiguous.lookup(target, profile, "procedure_info", parameters, (id) => + allRecords.get(id), + ), + ).toBeUndefined(); +}); + describe("executions that report truncated references", () => { const record = (referencesTruncated: boolean | undefined) => { const cache = new AnalysisSnapshotCache(); diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 49dabfbffc..d0b5e1883a 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -46,6 +46,7 @@ import { snapshotReplayBinding, type SnapshotReplayBinding, } from "./AnalysisSnapshotReplayBinding.js"; +import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js"; const RELATIONSHIP_OPERATIONS: ReadonlySet = new Set([ "procedure_callers", @@ -162,9 +163,12 @@ export const SNAPSHOT_REPLAY_LIMITATION = /** Bounded in-memory cache for one immutable binary identity. */ export class AnalysisSnapshotCache { readonly #entries = new Map(); - // Null means imported provenance cannot be faithfully replayed. It is distinct - // from an entry recorded before an Evidence ID was assigned. - readonly #replayBindings = new Map(); + // Null is ambiguous provenance; a pending ID is an exact parent whose source + // closure was missing and can be checked again against the owning ledger. + readonly #replayBindings = new Map< + string, + SnapshotReplayBinding | { readonly pendingEvidenceId: string } | null + >(); readonly #workflowEntries = new Map(); #target: AnalysisSnapshotTarget | undefined; #binding: AnalysisSnapshotBinding | undefined; @@ -233,6 +237,10 @@ export class AnalysisSnapshotCache { record, ]), ); + const lookup = (id: string) => records.get(id); + const checkClosure = createInvestigationEvidenceClosureChecker({ + evidenceForAnalysis: lookup, + }); const ambiguous = new Set(); for (const evidence of snapshot.evidence_bundle.records) { const queryId = analysisQueryId( @@ -257,11 +265,15 @@ export class AnalysisSnapshotCache { else evidenceByQuery.set(queryId, evidence); } for (const [queryId, evidence] of evidenceByQuery) { + if (ambiguous.has(queryId)) { + this.#replayBindings.set(queryId, null); + continue; + } this.#replayBindings.set( queryId, - ambiguous.has(queryId) - ? null - : snapshotReplayBinding(evidence, records), + snapshotReplayBinding(evidence, lookup, checkClosure) ?? { + pendingEvidenceId: evidence.evidence_id, + }, ); } for (const entry of snapshot.workflow_entries) { @@ -287,7 +299,12 @@ export class AnalysisSnapshotCache { replacements.map(({ current }) => [current.evidence_id, current]), ); for (const [queryId, binding] of this.#replayBindings) { - if (binding === null || binding.sourceEvidence === undefined) continue; + if ( + binding === null || + "pendingEvidenceId" in binding || + binding.sourceEvidence === undefined + ) + continue; const sources = binding.sourceEvidence; if (!sources.some((source) => currentRecords.has(source.evidence_id))) continue; @@ -490,6 +507,7 @@ export class AnalysisSnapshotCache { profile: AnalysisProfileCommitment, operation: AnalysisOperation, parameters: Readonly>, + lookup?: (id: string) => Evidence | undefined, ): AnalysisExecution | undefined { const queryId = analysisQueryId( snapshotTarget(target), @@ -499,10 +517,18 @@ export class AnalysisSnapshotCache { ); const cached = this.#entries.get(queryId); if (cached === undefined) return undefined; - const binding = this.#replayBindings.get(queryId); + let binding = this.#replayBindings.get(queryId); // Do not turn a refused provenance selection into a fresh, unlinked record. // The provider must produce a new observation before this query can replay. if (binding === null) return undefined; + if (binding !== undefined && "pendingEvidenceId" in binding) { + const parent = lookup?.(binding.pendingEvidenceId); + if (parent === undefined || lookup === undefined) return undefined; + const restored = snapshotReplayBinding(parent, lookup); + if (restored === null) return undefined; + this.#replayBindings.set(queryId, restored); + binding = restored; + } const cachedSubject = cached.execution.subject; const subject = cachedSubject == null @@ -550,22 +576,31 @@ export class AnalysisSnapshotCache { } /** Record one successful immutable call unless the cache is full. */ - record(input: { - readonly target: BinaryTarget; - readonly profile: AnalysisProfileCommitment; - readonly operation: AnalysisOperation; - readonly parameters: Readonly>; - readonly execution: AnalysisExecution; - readonly evidence?: Evidence; - }): void { + record( + input: { + readonly target: BinaryTarget; + readonly profile: AnalysisProfileCommitment; + readonly operation: AnalysisOperation; + readonly parameters: Readonly>; + readonly execution: AnalysisExecution; + readonly evidence?: Evidence; + }, + lookup?: (id: string) => Evidence | undefined, + ): void { const { target, profile, operation, parameters, execution } = input; this.select(target, profile); // The snapshot format does not carry reference truncation; a replay would // present a capped read as complete, so it is not recorded. if (execution.referencesTruncated === true) return; - // Provider snapshot entries do not carry execution source records. Replay - // must not discard the source closure that supplies the parent's Evidence links. - if ((execution.sourceEvidence?.length ?? 0) > 0) return; + let sourceBinding: SnapshotReplayBinding | undefined; + if ((execution.sourceEvidence?.length ?? 0) > 0) { + if (execution.evidenceId === undefined || lookup === undefined) return; + const parent = lookup(execution.evidenceId); + if (parent === undefined) return; + const closed = snapshotReplayBinding(parent, lookup); + if (closed === null) return; + sourceBinding = closed; + } const queryId = analysisQueryId( snapshotTarget(target), snapshotBinding(profile), @@ -597,12 +632,15 @@ export class AnalysisSnapshotCache { if (execution.evidenceId === undefined) this.#replayBindings.delete(entry.query_id); else - this.#replayBindings.set(entry.query_id, { - evidenceId: execution.evidenceId, - ...(input.evidence === undefined - ? {} - : { sourceEvidence: Object.freeze([input.evidence]) }), - }); + this.#replayBindings.set( + entry.query_id, + sourceBinding ?? { + evidenceId: execution.evidenceId, + ...(input.evidence === undefined + ? {} + : { sourceEvidence: Object.freeze([input.evidence]) }), + }, + ); } /** Forget all target-bound entries. */ diff --git a/src/application/binary/AnalysisSnapshotReplayBinding.ts b/src/application/binary/AnalysisSnapshotReplayBinding.ts index 5d6dfc2027..c24d7549e3 100644 --- a/src/application/binary/AnalysisSnapshotReplayBinding.ts +++ b/src/application/binary/AnalysisSnapshotReplayBinding.ts @@ -1,4 +1,5 @@ import type { Evidence } from "../../domain/evidence.js"; +import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js"; /** Authenticated observation identity and its immediate source records. */ export interface SnapshotReplayBinding { @@ -9,11 +10,15 @@ export interface SnapshotReplayBinding { /** Preserve direct links using the snapshot's authenticated immutable records. */ export const snapshotReplayBinding = ( evidence: Evidence, - records: ReadonlyMap, + lookup: (id: string) => Evidence | undefined, + checkClosure = createInvestigationEvidenceClosureChecker({ + evidenceForAnalysis: lookup, + }), ): SnapshotReplayBinding | null => { + if (!checkClosure(evidence.evidence_links)) return null; const sources: Evidence[] = []; for (const id of evidence.evidence_links) { - const source = records.get(id); + const source = lookup(id); // An external link without its source cannot be represented by sourceEvidence. if (source === undefined) return null; sources.push(source); diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index 3333bc37d6..4289861d37 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -323,14 +323,16 @@ export abstract class BinarySessionRecords { >, ): AnalysisExecution | undefined { if (this.#snapshotInvalidated) return undefined; - return this.#snapshot.lookup(target, profile, operation, parameters); + return this.#snapshot.lookup(target, profile, operation, parameters, (id) => + this.evidenceForAnalysis(id), + ); } protected recordSnapshot( input: Parameters[0], ): void { if (this.#snapshotInvalidated) return; - this.#snapshot.record(input); + this.#snapshot.record(input, (id) => this.evidenceForAnalysis(id)); this.#emitSnapshotChanged(); } diff --git a/src/application/investigation/InvestigationEvidence.test.ts b/src/application/investigation/InvestigationEvidence.test.ts index be2721fe9a..f62926e093 100644 --- a/src/application/investigation/InvestigationEvidence.test.ts +++ b/src/application/investigation/InvestigationEvidence.test.ts @@ -5,7 +5,10 @@ import { createEvidenceBundle } from "../../domain/evidenceBundle.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js"; import { InvestigationRecords } from "./InvestigationRecords.js"; -import { resolveInvestigationEvidenceClosure } from "./InvestigationEvidence.js"; +import { + createInvestigationEvidenceClosureChecker, + resolveInvestigationEvidenceClosure, +} from "./InvestigationEvidence.js"; const observation = (path: string, links: readonly string[] = []): Evidence => createEvidence( @@ -108,3 +111,50 @@ it("rejects a lookup that returns another semantic Evidence identity", () => { }, }); }); + +it("resolves an authenticated source with a wide repeated-link array", () => { + const leaf = observation("/wide-leaf"); + const source = observation( + "/wide-source", + Array(500_000).fill(leaf.evidence_id), + ); + expect(source.evidence_links).toHaveLength(500_000); + const records = new InvestigationRecords(); + for (const record of [leaf, source]) { + const added = records.recordEvidence(record); + if (!added.ok) throw added.error; + } + expect( + records.evidenceForAnalysis(source.evidence_id)?.evidence_links, + ).toHaveLength(500_000); + expect( + resolveInvestigationEvidenceClosure(records, [source.evidence_id]), + ).toEqual({ + ok: true, + value: [source.evidence_id, leaf.evidence_id].sort(), + }); +}); + +it("reuses a complete shared source graph without weakening missing-source checks", () => { + const leaf = observation("/shared-leaf"); + const source = observation("/shared-source", [leaf.evidence_id]); + const records = new Map( + [leaf, source].map((record) => [record.evidence_id, record]), + ); + let lookups = 0; + const check = createInvestigationEvidenceClosureChecker({ + evidenceForAnalysis: (id) => { + lookups++; + return records.get(id); + }, + }); + expect(check([source.evidence_id])).toBe(true); + expect(lookups).toBe(2); + expect(check([source.evidence_id, leaf.evidence_id])).toBe(true); + expect(lookups).toBe(2); + const missing = `ev_${"0".repeat(64)}`; + expect(check([missing])).toBe(false); + expect(check([missing])).toBe(false); + expect(lookups).toBe(3); + expect(check([source.evidence_id, missing])).toBe(false); +}); diff --git a/src/application/investigation/InvestigationEvidence.ts b/src/application/investigation/InvestigationEvidence.ts index d06175b4ca..819231b530 100644 --- a/src/application/investigation/InvestigationEvidence.ts +++ b/src/application/investigation/InvestigationEvidence.ts @@ -5,6 +5,37 @@ import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import type { Evidence } from "../../domain/evidence.js"; import { ok, type Result } from "../../domain/result.js"; +type Lookup = (id: string) => Evidence | undefined; + +/** Traverse without expanding a wide link array into function arguments. */ +const visitClosure = ( + lookup: Lookup, + seedIds: readonly string[], + knownComplete?: ReadonlySet, +): Result, EvidenceIntegrityError> => { + const visited = new Set(); + const pending = [...seedIds]; + while (pending.length > 0) { + const evidenceId = pending.pop(); + if ( + evidenceId === undefined || + visited.has(evidenceId) || + knownComplete?.has(evidenceId) + ) + continue; + const evidence = resolveEvidenceInput( + { kind: "retained-evidence", evidence_id: evidenceId }, + lookup, + "retained-closure", + ); + if (!evidence.ok) return evidence; + visited.add(evidenceId); + for (const linkedId of evidence.value.evidence_links) + pending.push(linkedId); + } + return ok(visited); +}; + /** Follow exact session references without exporting unrelated records or Unknowns. */ export const resolveInvestigationEvidenceClosure = ( reader: Pick, @@ -16,21 +47,32 @@ export const resolveInvestigationEvidenceClosure = ( const supplied = new Map( sources.map((source) => [source.evidence_id, source]), ); - const lookup = (id: string) => - supplied.get(id) ?? reader.evidenceForAnalysis(id); - const visited = new Set(); - const pending = [...seedIds]; - while (pending.length > 0) { - const evidenceId = pending.pop(); - if (evidenceId === undefined || visited.has(evidenceId)) continue; - const evidence = resolveEvidenceInput( - { kind: "retained-evidence", evidence_id: evidenceId }, - lookup, - "retained-closure", - ); - if (!evidence.ok) return evidence; - visited.add(evidenceId); - for (const link of evidence.value.evidence_links) pending.push(link); - } - return ok(uniqueSorted([...visited])); + const visited = visitClosure( + (id) => supplied.get(id) ?? reader.evidenceForAnalysis(id), + seedIds, + ); + return visited.ok ? ok(uniqueSorted([...visited.value])) : visited; +}; + +/** Reuse closure decisions while the caller's lookup remains immutable. */ +export const createInvestigationEvidenceClosureChecker = ( + reader: Pick, +): ((seedIds: readonly string[]) => boolean) => { + const complete = new Set(); + const incomplete = new Set(); + return (seedIds) => { + const single = seedIds.length === 1 ? seedIds[0] : undefined; + if (single !== undefined && incomplete.has(single)) return false; + const visited = visitClosure( + (id) => reader.evidenceForAnalysis(id), + seedIds, + complete, + ); + if (!visited.ok) { + if (single !== undefined) incomplete.add(single); + return false; + } + for (const id of visited.value) complete.add(id); + return true; + }; }; diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 748fe72662..1479df1d3b 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -131,6 +131,19 @@ const expectWorkflowQuestionsRetained = ( ); }; +const expectSourceSnapshotRetained = (snapshot: AnalysisSnapshot) => { + expect(snapshot.entries).toHaveLength(1); + const parent = snapshot.evidence_bundle.records.find( + (record) => record.operation === "list_strings", + ); + expect(parent?.evidence_links).toHaveLength(1); + expect(snapshot.evidence_bundle.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ evidence_id: parent?.evidence_links[0] }), + ]), + ); +}; + interface SnapshotProviderTrace { readonly starts: string[]; readonly queries: Array<{ @@ -145,6 +158,7 @@ interface SnapshotProviderOptions { readonly identity?: ProviderIdentity; readonly supportedOperations?: readonly CapabilityDescriptor["operation"][]; readonly limitations?: readonly string[]; + readonly sourceEvidence?: boolean; } const makeProvider = ( @@ -181,7 +195,7 @@ const makeProvider = ( trace.resolutions.push(profile.digest); return ok({ profile }); }, - createClient: () => { + createClient: (target) => { trace.starts.push("start"); return { execute: async (operation, parameters) => { @@ -242,6 +256,17 @@ const makeProvider = ( analysisProfile: profile, rawResult: result, limitations: options.limitations ?? [], + ...(options.sourceEvidence + ? { + sourceEvidence: [ + createEvidence(target, profile.provider, { + operation: "health", + parameters: {}, + result: "fixture source", + }), + ], + } + : {}), }), ); }, @@ -550,13 +575,18 @@ const withEarlierHistoricalEvidence = ( }; describe("snapshot output cleanup lifecycle", () => { - it.each(["cleanup", "cancelled"] as const)( - "preserves the committed snapshot receipt after %s finalization and retry", - async (failureKind) => { + it.each([ + { failureKind: "cleanup", sourceEvidence: false }, + { failureKind: "cancelled", sourceEvidence: false }, + { failureKind: "cleanup", sourceEvidence: true }, + { failureKind: "cancelled", sourceEvidence: true }, + ] as const)( + "preserves a committed snapshot after $failureKind finalization with source=$sourceEvidence", + async ({ failureKind, sourceEvidence }) => { const fixture = await createSnapshotReplayFixture( "rea-snapshot-close-failure-", ); - const provider = fixture.createProvider(); + const provider = fixture.createProvider({ sourceEvidence }); const failure = failureKind === "cleanup" ? new ProviderCleanupError( @@ -587,7 +617,9 @@ describe("snapshot output cleanup lifecycle", () => { try { const opened = await session.open(fixture.path); if (!opened.ok) throw opened.error; - const analyzed = await session.execute("list_segments", {}); + const analyzed = sourceEvidence + ? await session.execute("list_strings", { document: "fixture" }) + : await session.execute("list_segments", {}); if (!analyzed.ok) throw analyzed.error; const closed = await session.closeWithSnapshot( fixture.snapshotPath, @@ -598,6 +630,7 @@ describe("snapshot output cleanup lifecycle", () => { const committed = await readFile(fixture.snapshotPath); const snapshot = await readAnalysisSnapshot(fixture.snapshotPath); if (!snapshot.ok) throw snapshot.error; + if (sourceEvidence) expectSourceSnapshotRetained(snapshot.value); const receipt = { path: fixture.snapshotPath, bytes: committed.byteLength, @@ -1837,6 +1870,66 @@ it("retains a native dispatch workflow snapshot without a deep provider profile" ); }); +it("replays source-bearing direct analysis only while snapshot source Evidence is present", async () => { + const fixture = await createSnapshotReplayFixture( + "rea-direct-source-replay-", + ); + const provider = fixture.createProvider({ + supportedOperations: ["procedure_pseudo_code"], + sourceEvidence: true, + }); + const dependencies = fixture.dependencies(provider); + const parameters = { procedure: "0x1000", document: "fixture" }; + const options = { snapshotPath: fixture.snapshotPath }; + const run = () => + runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + parameters, + options, + ).then(projectCliAnalysisResult); + const first = parseEvidence(await run()); + expect(first.evidence_links).toHaveLength(1); + const saved = await readAnalysisSnapshot(fixture.snapshotPath); + if (!saved.ok) throw saved.error; + expect(saved.value.entries).toHaveLength(1); + const sourceId = first.evidence_links[0]; + expect( + saved.value.evidence_bundle.records.some( + (record) => record.evidence_id === sourceId, + ), + ).toBe(true); + const initialStarts = fixture.starts.length; + expect(parseEvidence(await run()).evidence_links).toEqual( + first.evidence_links, + ); + expect(fixture.starts).toHaveLength(initialStarts); + + const withoutSource = { + ...saved.value, + evidence_bundle: createEvidenceBundle( + saved.value.evidence_bundle.records.filter( + (record) => record.evidence_id !== sourceId, + ), + ), + }; + const written = await withArtifactResourceScope((resources) => + writeAnalysisSnapshot(withoutSource, fixture.snapshotPath, true, resources), + ); + if (!written.ok) throw written.error; + const refreshed = parseEvidence(await run()); + expect(fixture.starts).toHaveLength(initialStarts + 1); + expect(refreshed.evidence_links).toEqual(first.evidence_links); + const repaired = await readAnalysisSnapshot(fixture.snapshotPath); + if (!repaired.ok) throw repaired.error; + expect( + repaired.value.evidence_bundle.records.some( + (record) => record.evidence_id === sourceId, + ), + ).toBe(true); +}); + it("runs decompile export against the provider after loading a pseudocode snapshot", async () => { const fixture = await createSnapshotReplayFixture("rea-directory-snapshot-"); const original = fixture.createProvider({ diff --git a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts index 4cb6188fbc..64b460b021 100644 --- a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts +++ b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts @@ -22,9 +22,13 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js"; import { ok } from "../../../src/domain/result.js"; -import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { + createEvidenceBundle, + evidenceBundleSchema, +} from "../../../src/domain/evidenceBundle.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; @@ -578,6 +582,209 @@ describe("MCP investigation history snapshot replay", () => { ); }); +it("replays a source-bearing observation through an exported MCP snapshot", async () => { + const { targetPaths, snapshotPath } = await createWorkflowFiles( + "rea-mcp-source-replay-", + ["fixture.hop"], + ); + const targetPath = targetPaths[0]; + if (targetPath === undefined) throw new Error("Expected a fixture target"); + const starts: string[] = []; + const calls: string[] = []; + const base = makeProvider(starts, calls); + const provider: AnalysisProvider = { + ...base, + createClient: (target, selectedProfile, context) => { + const client = base.createClient(target, selectedProfile, context); + return { + ...client, + execute: async (...args) => { + const result = await client.execute(...args); + if (!result.ok || args[0] !== "list_strings") return result; + return ok({ + ...result.value, + sourceEvidence: [ + createEvidence(target, identity, { + operation: "health", + parameters: {}, + result: "historical source", + }), + ], + }); + }, + }; + }, + }; + const { mcp } = await connectWorkflowMcp( + createWorkflowSession(provider), + "source-replay", + ); + await openWorkflowTarget(mcp, targetPath); + const read = (client: Client) => + client.callTool({ + name: "list_strings", + arguments: { document: "fixture" }, + }); + const first = await read(mcp); + const firstEvidence = toolContract("list_strings").outputSchema.parse( + first.structuredContent, + ); + expect(firstEvidence.evidence_links).toHaveLength(1); + const second = await read(mcp); + const secondEvidence = toolContract("list_strings").outputSchema.parse( + second.structuredContent, + ); + expect(secondEvidence.evidence_links).toEqual( + expect.arrayContaining([ + ...firstEvidence.evidence_links, + firstEvidence.evidence_id, + ]), + ); + expect(secondEvidence.evidence_links).toHaveLength(2); + expect(secondEvidence.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("did not re-run the provider"), + ]), + ); + expect( + calls.filter((operation) => operation === "list_strings"), + ).toHaveLength(1); + expect( + ( + await mcp.callTool({ + name: "close_binary", + arguments: { snapshot_path: snapshotPath }, + }) + ).isError, + ).not.toBe(true); + const { mcp: reopened } = await connectWorkflowMcp( + createWorkflowSession(provider), + "source-replay-import", + ); + expect( + ( + await reopened.callTool({ + name: "open_binary", + arguments: { path: targetPath, snapshot_path: snapshotPath }, + }) + ).isError, + ).not.toBe(true); + const replayed = toolContract("list_strings").outputSchema.parse( + (await read(reopened)).structuredContent, + ); + expect(replayed.evidence_links).toEqual( + expect.arrayContaining([ + ...firstEvidence.evidence_links, + firstEvidence.evidence_id, + ]), + ); + expect(replayed.evidence_links).toHaveLength(2); + expect(replayed.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("did not re-run the provider"), + ]), + ); + expect( + calls.filter((operation) => operation === "list_strings"), + ).toHaveLength(1); + const bundle = await reopened.callTool({ + name: "get_evidence_bundle", + arguments: { + detail: "records", + filters: { evidence_id: firstEvidence.evidence_links[0] }, + }, + }); + expect(bundle.isError).not.toBe(true); + expect( + toolContract("get_evidence_bundle").outputSchema.parse( + bundle.structuredContent, + ).result, + ).toMatchObject({ + records: [ + { + evidence_id: firstEvidence.evidence_links[0], + normalized_result: "historical source", + }, + ], + }); + await expectRestoredSourceReplay(provider, targetPath, snapshotPath, calls); +}); + +const expectRestoredSourceReplay = async ( + provider: AnalysisProvider, + targetPath: string, + snapshotPath: string, + calls: string[], +) => { + const saved = await readAnalysisSnapshot(snapshotPath); + if (!saved.ok) throw saved.error; + const source = saved.value.evidence_bundle.records.find( + (record) => record.operation === "health", + ); + if (source === undefined) throw new Error("Expected historical source"); + const directory = dirname(snapshotPath); + const prunedPath = join(directory, "missing-source.json"); + const sourcePath = join(directory, "source-bundle.json"); + await writeFile( + prunedPath, + JSON.stringify({ + ...saved.value, + evidence_bundle: createEvidenceBundle( + saved.value.evidence_bundle.records.filter( + (record) => record.evidence_id !== source.evidence_id, + ), + ), + }), + ); + await writeFile(sourcePath, JSON.stringify(createEvidenceBundle([source]))); + const { mcp } = await connectWorkflowMcp( + createWorkflowSession(provider), + "source-restored-after-open", + ); + expect( + ( + await mcp.callTool({ + name: "open_binary", + arguments: { path: targetPath, snapshot_path: prunedPath }, + }) + ).isError, + ).not.toBe(true); + expect( + ( + await mcp.callTool({ + name: "import_evidence_bundle", + arguments: { path: sourcePath }, + }) + ).isError, + ).not.toBe(true); + const replayed = toolContract("list_strings").outputSchema.parse( + ( + await mcp.callTool({ + name: "list_strings", + arguments: { document: "fixture" }, + }) + ).structuredContent, + ); + const parent = saved.value.evidence_bundle.records.find( + (record) => + record.operation === "list_strings" && + record.evidence_links.includes(source.evidence_id), + ); + if (parent === undefined) throw new Error("Expected original observation"); + expect(replayed.evidence_links).toEqual( + expect.arrayContaining([source.evidence_id, parent.evidence_id]), + ); + expect(replayed.evidence_links).toHaveLength(2); + expect(replayed.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("did not re-run the provider"), + ]), + ); + expect( + calls.filter((operation) => operation === "list_strings"), + ).toHaveLength(1); +}; + const expectSnapshotImportGuidance = async (mcp: Client, path: string) => { const before = await readFile(path); const imported = await mcp.callTool({