Files
rea/src/application/EvidenceBundleFiles.ts
T
gensokyo-dev f72638f6b1 feat(investigation)!: retain records across engine cleanup (#8361)
Separate engine cleanup, investigation reset and terminal disposal in the shared application owner. Keep stable Evidence IDs and full Unknown revision history through engine close and target changes; serialize reset with all record writers and cleanup owners, optionally acknowledging a complete checkpoint before clearing. Bound retained representation without eviction.

Validation: 43 focused files / 533 distinct tests, source/type/lint/schema/documentation checks, nine genuine source cases across eight families, real Ghidra session identity and cleanup, and nine actual local Codex CLI terra/high runs with independent full-result and checkpoint audits. See docs/verification/investigation-lifecycle.md for measurements, partial attempts and verification limits.

Fixes #8347.

BREAKING CHANGE: close_binary retains investigation records. Use reset_investigation to clear them. Cross-process recovery requires an explicitly acknowledged Evidence bundle checkpoint.

Assisted-by: Suika (gensokyo-skills:suika-scatter-gather-planning)
2026-10-31 21:34:45 +00:00

256 lines
8.4 KiB
TypeScript

import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { EvidenceBundleExportReceipt } from "../domain/investigationLifecycle.js";
import { jsonValueValidationIssue } from "../domain/jsonValue.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { describeValidationFailure } from "../domain/validationFailure.js";
import {
safeParseImmutableEvidenceBundle,
safeParseEvidenceBundle,
isImmutableEvidenceBundle,
type EvidenceBundle,
} from "../domain/evidenceBundle.js";
import {
EvidenceFileError,
EvidenceIntegrityError,
type PublishedEvidenceFile,
} from "../domain/evidenceErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
AnalysisCancelledError,
type AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { safeParseProcessCapture } from "../domain/process/processCaptureParsing.js";
import {
bufferedJsonParts,
canonicalJsonParts,
} from "../domain/jsonSerialization.js";
import { readJsonFile, writeTextParts } from "./JsonFiles.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
type EvidenceReadFailure =
| EvidenceFileError
| EvidenceIntegrityError
| AnalysisResourceConstraintError
| AnalysisCancelledError;
type EvidenceWriteFailure =
| EvidenceFileError
| EvidenceIntegrityError
| AnalysisCancelledError
| AnalysisResourceConstraintError;
/** Project a published bundle with record and residual-unknown revision counts. */
export const evidenceBundleExportReceipt = (
bundle: EvidenceBundle,
published: PublishedEvidenceFile,
): EvidenceBundleExportReceipt => ({
path: published.path,
bytes: published.bytes,
records: bundle.records.length,
unknowns: bundle.unknowns.length,
});
/** Read and validate an evidence bundle at the caller-supplied path. */
export const readEvidenceBundle = (
path: string,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
readBundle(path, false, signal, resources, operation);
/** Read an authenticated immutable snapshot for a subsequent session import. */
export const readImmutableEvidenceBundle = (
path: string,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
readBundle(path, true, signal, resources, operation);
/** Join both comparison reads and preserve cleanup owned by either failed input. */
export const readComparisonEvidenceBundles = async (
leftPath: string,
rightPath: string,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "compare_bundles",
): Promise<
Result<
{ readonly left: EvidenceBundle; readonly right: EvidenceBundle },
AnalysisError
>
> => {
const [left, right] = await Promise.all([
readImmutableEvidenceBundle(leftPath, signal, resources, operation),
readImmutableEvidenceBundle(rightPath, signal, resources, operation),
]);
if (!left.ok) {
if (!right.ok && right.error.cleanupIncomplete)
return err(
analysisErrorWithCleanupFailure(
left.error,
right.error,
operation,
).bindCleanupDependencies([left.error, right.error]),
);
return left;
}
if (!right.ok) return right;
return ok({ left: left.value, right: right.value });
};
const readBundle = async (
path: string,
immutable: boolean,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
if (signal?.aborted)
return err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
);
const loaded = await readJsonFile(path, signal, resources, operation);
if (!loaded.ok) return loaded;
if (signal?.aborted)
return err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
);
return authenticateEvidenceBundleValue(loaded.value, path, {
operation,
immutable,
});
};
/** Authenticate a loaded bundle once, preserving file-reader failure and process-capture semantics. */
export const authenticateEvidenceBundleValue = (
value: unknown,
path: string,
options: { readonly operation: string; readonly immutable?: boolean },
): Result<EvidenceBundle, EvidenceReadFailure> => {
const { operation } = options;
const headroom = requireAuthenticationHeadroom(value, {
operation,
path: absoluteCallerPath(path),
description:
"Insufficient heap headroom to authenticate the Evidence bundle",
});
if (!headroom.ok) return headroom;
const parsed =
options.immutable === true
? safeParseImmutableEvidenceBundle(value)
: safeParseEvidenceBundle(value);
if (!parsed.ok) {
const failure = parsed.error;
if (failure.kind === "single-record")
return err(
new EvidenceFileError("read", "wrong-kind", {
path: absoluteCallerPath(path),
cause: failure.cause,
userMessage: failure.message,
}),
);
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause: failure.cause,
userMessage: failure.message,
}),
);
}
return validateBundlePayloads(parsed.value, path, operation);
};
/** Validate typed payloads without restricting historical workflow or provider identities. */
const validateBundlePayloads = (
bundle: EvidenceBundle,
path: string,
operation: string,
): Result<
EvidenceBundle,
EvidenceIntegrityError | AnalysisResourceConstraintError
> => {
for (const record of bundle.records) {
if (record.predicate_type !== "rea.process-capture") continue;
// A sealed bundle borrows records, but this typed parser still allocates a
// capture copy. Admit its actual working set against current headroom.
const headroom = requireAuthenticationHeadroom(record.normalized_result, {
operation,
path: absoluteCallerPath(path),
description: `Insufficient heap headroom to validate process capture ${record.evidence_id} in the Evidence bundle`,
});
if (!headroom.ok) return headroom;
const parsedCapture = safeParseProcessCapture(record.normalized_result);
if (!parsedCapture.success) {
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause: parsedCapture.error,
userMessage: `Process capture record ${record.evidence_id} has an invalid normalized_result (${describeValidationFailure(parsedCapture.error)}). Recreate or re-export the bundle, then try again.`,
}),
);
}
}
return ok(bundle);
};
/** Atomically write deterministic evidence JSON at the caller-supplied path. */
export const writeEvidenceBundle = async (
bundle: EvidenceBundle,
path: string,
overwrite: boolean,
resources: ArtifactResourceScope,
signal?: AbortSignal,
operation = "export_evidence_bundle",
): Promise<
Result<
{ readonly path: string; readonly bytes: number },
EvidenceWriteFailure
>
> => {
if (signal?.aborted === true)
return err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
);
if (!isImmutableEvidenceBundle(bundle)) {
const issue = jsonValueValidationIssue(bundle);
if (issue !== undefined)
return err(
new EvidenceIntegrityError("Evidence checkpoint is not ordinary JSON", {
userMessage: issue,
}),
);
const headroom = requireAuthenticationHeadroom(bundle, {
operation,
path: absoluteCallerPath(path),
description:
"Insufficient heap headroom to authenticate the Evidence checkpoint",
});
if (!headroom.ok) return headroom;
}
const checked = safeParseEvidenceBundle(bundle);
if (!checked.ok) {
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause: checked.error.cause,
}),
);
}
const validated = validateBundlePayloads(checked.value, path, operation);
if (!validated.ok) return validated;
return writeTextParts(
bufferedJsonParts(canonicalJsonParts(validated.value)),
path,
overwrite,
resources,
signal === undefined ? undefined : { signal, operation },
);
};