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
This commit is contained in:
morluto authored and GitHub committed 2026-11-07 16:22:21 +08:00
1 parent b31fc5d5fb
commit 441ba94086
9 files changed
+660 -62

No files matched your search

+20 -8
View File
@@ -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);
}
}
}
}
@@ -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();
+63 -25
View File
@@ -46,6 +46,7 @@ import {
snapshotReplayBinding,
type SnapshotReplayBinding,
} from "./AnalysisSnapshotReplayBinding.js";
import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js";
const RELATIONSHIP_OPERATIONS: ReadonlySet<AnalysisOperation> = 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<string, AnalysisSnapshotEntry>();
// 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<string, SnapshotReplayBinding | null>();
// 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<string, AnalysisSnapshotWorkflowEntry>();
#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<string>();
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<Record<string, JsonValue>>,
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<Record<string, JsonValue>>;
readonly execution: AnalysisExecution;
readonly evidence?: Evidence;
}): void {
record(
input: {
readonly target: BinaryTarget;
readonly profile: AnalysisProfileCommitment;
readonly operation: AnalysisOperation;
readonly parameters: Readonly<Record<string, JsonValue>>;
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. */
@@ -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<string, Evidence>,
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);
@@ -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<AnalysisSnapshotCache["record"]>[0],
): void {
if (this.#snapshotInvalidated) return;
this.#snapshot.record(input);
this.#snapshot.record(input, (id) => this.evidenceForAnalysis(id));
this.#emitSnapshotChanged();
}
@@ -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);
});
@@ -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<string>,
): Result<Set<string>, EvidenceIntegrityError> => {
const visited = new Set<string>();
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<EvidenceReader, "evidenceForAnalysis">,
@@ -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<string>();
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",
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<EvidenceReader, "evidenceForAnalysis">,
): ((seedIds: readonly string[]) => boolean) => {
const complete = new Set<string>();
const incomplete = new Set<string>();
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 (!evidence.ok) return evidence;
visited.add(evidenceId);
for (const link of evidence.value.evidence_links) pending.push(link);
}
return ok(uniqueSorted([...visited]));
if (!visited.ok) {
if (single !== undefined) incomplete.add(single);
return false;
}
for (const id of visited.value) complete.add(id);
return true;
};
};
@@ -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({
@@ -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({