mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
1 parent
b31fc5d5fb
commit
441ba94086
9 files changed
+660
-62
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user