mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 03:15:54 +00:00
Retain native-only dispatch workflows without requiring a deep provider profile. Validate exact target, operation, and current reader bindings during open, import, recording, and export; preserve selected deep bindings and caller limits. Validated public CLI and stdio MCP save/reopen, exact CLI replay, changed-limit cache partitioning, baseline failure and candidate success on an Apple-clang fixture, snapshot lifecycle regressions, typecheck and lint. No target or deep engine execution. Fixes #5529.
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
|
import { analysisProfilesEqual } from "../../domain/analysisProfile.js";
|
|
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
|
|
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
|
import type { Evidence } from "../../domain/evidence.js";
|
|
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
|
|
import type { JsonValue } from "../../domain/jsonValue.js";
|
|
import { evidenceBundleForTarget } from "../../domain/evidenceBundle.js";
|
|
import {
|
|
EvidenceFileError,
|
|
EvidenceIntegrityError,
|
|
AnalysisSnapshotMismatchError,
|
|
} from "../../domain/evidenceErrors.js";
|
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import type { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
|
import type { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
|
import type {
|
|
RecordUnknownInput,
|
|
ResidualUnknown,
|
|
UnknownStatus,
|
|
UpdateUnknownInput,
|
|
} from "../../domain/residualUnknown.js";
|
|
import type {
|
|
UnknownPage,
|
|
UnknownQuery,
|
|
} from "../investigation/InvestigationRecordPort.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import type {
|
|
AnalysisExecution,
|
|
AnalysisOperation,
|
|
} from "../AnalysisProvider.js";
|
|
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
|
|
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
|
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
|
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
|
import { writeAnalysisSnapshot as writeSnapshotFile } from "./AnalysisSnapshotFiles.js";
|
|
import { workflowAnalysisProfile } from "../InvestigationProviders.js";
|
|
import {
|
|
hasOnlyProfilelessNativeDispatchEntries,
|
|
isProfilelessNativeDispatchSnapshot,
|
|
} from "./ProfilelessNativeWorkflowSnapshot.js";
|
|
|
|
const SNAPSHOT_MUTATION_RECOVERY =
|
|
"Export session observations through export_evidence_bundle if needed. Close the active target without saving a snapshot, then reopen it before importing or saving a snapshot.";
|
|
|
|
export interface ActiveAnalysisBinding {
|
|
readonly target: BinaryTarget;
|
|
readonly profile: AnalysisProfileCommitment | null;
|
|
/** Current route profile for the single profileless native dispatch workflow. */
|
|
readonly profilelessWorkflowProfile?: AnalysisProfileCommitment;
|
|
}
|
|
|
|
/** Validated composed-workflow payload bound to the active target and profile. */
|
|
export interface WorkflowSnapshotRecordInput {
|
|
readonly operation: AnalysisOperation;
|
|
readonly bindingProfile: AnalysisProfileCommitment;
|
|
readonly parameters: Readonly<Record<string, JsonValue>>;
|
|
readonly execution: Parameters<
|
|
AnalysisSnapshotCache["recordWorkflow"]
|
|
>[0]["execution"];
|
|
}
|
|
|
|
/** Own binary snapshots and coordinate the composed investigation records. */
|
|
export abstract class BinarySessionRecords {
|
|
readonly #records: InvestigationRecords;
|
|
readonly #writeResources = new ArtifactResourceScope();
|
|
|
|
protected get hasPendingFileResources(): boolean {
|
|
return this.#writeResources.hasPending;
|
|
}
|
|
readonly #snapshot = new AnalysisSnapshotCache();
|
|
#snapshotInvalidated = false;
|
|
readonly #snapshotListeners = new Set<() => void | Promise<void>>();
|
|
|
|
constructor(records: InvestigationRecords = new InvestigationRecords()) {
|
|
this.#records = records;
|
|
}
|
|
|
|
/** Observe changes to the mutable current analysis snapshot resource. */
|
|
onAnalysisSnapshotChanged(listener: () => void | Promise<void>): () => void {
|
|
this.#snapshotListeners.add(listener);
|
|
return () => this.#snapshotListeners.delete(listener);
|
|
}
|
|
|
|
recordEvidence(
|
|
evidence: Evidence,
|
|
): Result<"added" | "duplicate", EvidenceIntegrityError> {
|
|
const recorded = this.#records.recordEvidence(evidence);
|
|
if (recorded.ok && recorded.value === "added") this.#emitSnapshotChanged();
|
|
return recorded;
|
|
}
|
|
|
|
hasEvidence(evidenceId: string): boolean {
|
|
return this.#records.hasEvidence(evidenceId);
|
|
}
|
|
|
|
evidenceById(evidenceId: string): Evidence | undefined {
|
|
return this.#records.evidenceById(evidenceId);
|
|
}
|
|
|
|
/** Borrow immutable investigation Evidence without copying a complete graph. */
|
|
evidenceForAnalysis(evidenceId: string): Evidence | undefined {
|
|
return this.#records.evidenceForAnalysis(evidenceId);
|
|
}
|
|
|
|
exportEvidenceBundle(): EvidenceBundle {
|
|
return this.#records.exportEvidenceBundle();
|
|
}
|
|
|
|
/** Borrow a sealed bundle for complete serialization without cloning retained graphs. */
|
|
evidenceBundleForSerialization(): EvidenceBundle {
|
|
return this.#records.evidenceBundleForSerialization();
|
|
}
|
|
|
|
/** Persist one provider-neutral snapshot under this session's resource owner. */
|
|
writeAnalysisSnapshot(
|
|
snapshot: AnalysisSnapshot,
|
|
path: string,
|
|
overwrite: boolean,
|
|
): Promise<
|
|
Result<{ readonly path: string; readonly bytes: number }, AnalysisError>
|
|
> {
|
|
return writeSnapshotFile(snapshot, path, overwrite, this.#writeResources);
|
|
}
|
|
|
|
/** Retry and release staged snapshot files at the owning session shutdown. */
|
|
async closeWriteResources(): Promise<Result<null, AnalysisError>> {
|
|
try {
|
|
await this.#writeResources.retryCleanup();
|
|
return ok(null);
|
|
} catch (cause: unknown) {
|
|
const observation = ArtifactReaderFailure.cleanupObservation(
|
|
cause,
|
|
"analysis snapshot staging",
|
|
);
|
|
return err(
|
|
new EvidenceFileError("write", "io", {
|
|
cause,
|
|
cleanup: {
|
|
reason: observation.reason,
|
|
resources: observation.resources,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
importEvidenceBundle(
|
|
bundle: unknown,
|
|
): Result<number, EvidenceIntegrityError | AnalysisInputError> {
|
|
const imported = this.#records.mergeEvidenceBundle(bundle);
|
|
if (!imported.ok) return imported;
|
|
if (imported.value.metadataChanged) this.invalidateSnapshot();
|
|
else if (imported.value.changed) this.#emitSnapshotChanged();
|
|
return ok(imported.value.recordsAdded);
|
|
}
|
|
|
|
protected abstract activeAnalysisBinding(): ActiveAnalysisBinding | undefined;
|
|
|
|
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError> {
|
|
const active = this.activeAnalysisBinding();
|
|
if (this.#snapshotInvalidated)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Analysis snapshots are unavailable because analysis metadata may have changed",
|
|
{
|
|
userMessage: `Analysis snapshots are unavailable because analysis metadata may have changed. ${SNAPSHOT_MUTATION_RECOVERY}`,
|
|
},
|
|
),
|
|
);
|
|
const target = active?.target;
|
|
const profile =
|
|
active?.profile ?? active?.profilelessWorkflowProfile ?? undefined;
|
|
if (target !== undefined && profile === undefined)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Analysis snapshots require a concrete provider analysis profile",
|
|
),
|
|
);
|
|
if (
|
|
active?.profile === null &&
|
|
(target === undefined ||
|
|
profile === undefined ||
|
|
!this.#snapshot.matches(target, profile) ||
|
|
this.#snapshot.entries().length !== 0 ||
|
|
!hasOnlyProfilelessNativeDispatchEntries(
|
|
this.#snapshot.workflowEntries(),
|
|
profile,
|
|
))
|
|
)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Profileless native snapshots may contain only current dispatch workflow observations",
|
|
),
|
|
);
|
|
return this.#snapshot.export(
|
|
target,
|
|
profile,
|
|
target === undefined
|
|
? this.#records.exportEvidenceBundle()
|
|
: evidenceBundleForTarget(
|
|
this.#records.exportEvidenceBundle(),
|
|
target.sha256,
|
|
),
|
|
);
|
|
}
|
|
|
|
importAnalysisSnapshot(
|
|
snapshot: AnalysisSnapshot,
|
|
): Result<number, AnalysisError> {
|
|
const active = this.activeAnalysisBinding();
|
|
if (active !== undefined && this.#snapshotInvalidated)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Analysis snapshots cannot be imported because analysis metadata may have changed",
|
|
{
|
|
userMessage: `Analysis snapshots cannot be imported because analysis metadata may have changed. ${SNAPSHOT_MUTATION_RECOVERY}`,
|
|
},
|
|
),
|
|
);
|
|
const profile = active?.profile ?? active?.profilelessWorkflowProfile;
|
|
if (
|
|
active?.profile === null &&
|
|
(profile === undefined ||
|
|
!isProfilelessNativeDispatchSnapshot(snapshot, active.target, profile))
|
|
)
|
|
return err(
|
|
new AnalysisSnapshotMismatchError(
|
|
"Analysis snapshot profile_mismatch: the snapshot is not bound to the active native dispatch reader",
|
|
),
|
|
);
|
|
const imported = this.#snapshot.import(
|
|
snapshot,
|
|
active === undefined
|
|
? undefined
|
|
: profile === undefined
|
|
? undefined
|
|
: { target: active.target, profile },
|
|
(bundle) => {
|
|
const imported = this.#records.mergeEvidenceBundle(bundle);
|
|
return imported.ok ? ok(imported.value.recordsAdded) : imported;
|
|
},
|
|
);
|
|
if (imported.ok) this.#emitSnapshotChanged();
|
|
return imported;
|
|
}
|
|
|
|
protected matchesSnapshot(
|
|
target: BinaryTarget,
|
|
profile: AnalysisProfileCommitment | null,
|
|
): boolean {
|
|
return this.#snapshot.matches(target, profile ?? undefined);
|
|
}
|
|
|
|
protected selectSnapshot(
|
|
target: BinaryTarget,
|
|
profile: AnalysisProfileCommitment,
|
|
): void {
|
|
this.#snapshot.select(target, profile);
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
protected lookupSnapshot(
|
|
target: BinaryTarget,
|
|
profile: AnalysisProfileCommitment,
|
|
operation: AnalysisOperation,
|
|
parameters: Readonly<
|
|
Record<string, import("../../domain/jsonValue.js").JsonValue>
|
|
>,
|
|
): AnalysisExecution | undefined {
|
|
if (this.#snapshotInvalidated) return undefined;
|
|
return this.#snapshot.lookup(target, profile, operation, parameters);
|
|
}
|
|
|
|
protected recordSnapshot(
|
|
input: Parameters<AnalysisSnapshotCache["record"]>[0],
|
|
): void {
|
|
if (this.#snapshotInvalidated) return;
|
|
this.#snapshot.record(input);
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
/** Retain one derived workflow result alongside its provider cache entries. */
|
|
recordWorkflowSnapshot(
|
|
input: WorkflowSnapshotRecordInput,
|
|
): Result<null, EvidenceIntegrityError> {
|
|
const active = this.activeAnalysisBinding();
|
|
if (active === undefined)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Workflow snapshot entries require an active concrete provider profile",
|
|
),
|
|
);
|
|
if (this.#snapshotInvalidated) return ok(null);
|
|
const profile = active.profile ?? active.profilelessWorkflowProfile;
|
|
if (
|
|
profile === undefined ||
|
|
!analysisProfilesEqual(profile, input.bindingProfile) ||
|
|
!analysisProfilesEqual(
|
|
input.execution.analysisProfile,
|
|
workflowAnalysisProfile(profile, input.operation),
|
|
) ||
|
|
(active.profile === null &&
|
|
input.operation !== "inspect_native_dispatch_metadata")
|
|
)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Workflow snapshot entry does not match the active reader profile",
|
|
),
|
|
);
|
|
try {
|
|
this.#snapshot.recordWorkflow({
|
|
target: active.target,
|
|
profile,
|
|
...input,
|
|
});
|
|
this.#emitSnapshotChanged();
|
|
return ok(null);
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
cause instanceof Error
|
|
? cause.message
|
|
: "Workflow snapshot entry validation failed",
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
protected invalidateSnapshot(): void {
|
|
this.#snapshot.clear();
|
|
this.#snapshotInvalidated = true;
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
protected resetSnapshotInvalidation(): void {
|
|
if (!this.#snapshotInvalidated) return;
|
|
this.#snapshot.clear();
|
|
this.#snapshotInvalidated = false;
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
protected clearSnapshot(): void {
|
|
this.#snapshot.clear();
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
protected clearSessionRecords(): void {
|
|
this.#records.clear();
|
|
this.#snapshot.clear();
|
|
this.#snapshotInvalidated = false;
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
#emitSnapshotChanged(): void {
|
|
for (const listener of this.#snapshotListeners) {
|
|
try {
|
|
const notification = listener();
|
|
if (notification !== undefined)
|
|
void notification.catch((cause: unknown) => {
|
|
// best-effort cleanup: async observer notifications must not reject unhandled.
|
|
void cause;
|
|
});
|
|
} catch (cause: unknown) {
|
|
// External resource observers are best-effort; one callback must not
|
|
// make a committed evidence mutation appear to fail.
|
|
void cause;
|
|
}
|
|
}
|
|
}
|
|
|
|
recordUnknown(
|
|
input: RecordUnknownInput,
|
|
): Result<ResidualUnknown, AnalysisError> {
|
|
const target = this.activeAnalysisBinding()?.target;
|
|
const recorded = this.#records.recordUnknown(input, target);
|
|
if (recorded.ok) this.#emitSnapshotChanged();
|
|
return recorded;
|
|
}
|
|
|
|
recordEvidenceWithUnknown(
|
|
evidence: Evidence,
|
|
input: RecordUnknownInput,
|
|
): Result<ResidualUnknown | null, AnalysisError> {
|
|
const recorded = this.#records.recordEvidenceWithUnknown(evidence, input);
|
|
if (recorded.ok) this.#emitSnapshotChanged();
|
|
return recorded;
|
|
}
|
|
|
|
updateUnknown(
|
|
input: UpdateUnknownInput,
|
|
): Result<ResidualUnknown, AnalysisError> {
|
|
const target = this.activeAnalysisBinding()?.target;
|
|
const updated = this.#records.updateUnknown(input, target);
|
|
if (updated.ok) this.#emitSnapshotChanged();
|
|
return updated;
|
|
}
|
|
|
|
listUnknowns(
|
|
filters: {
|
|
readonly status?: UnknownStatus;
|
|
readonly severity?: ResidualUnknown["severity"];
|
|
readonly domain?: string;
|
|
} = {},
|
|
): ResidualUnknown[] {
|
|
return this.#records.listUnknowns(filters);
|
|
}
|
|
|
|
listUnknownsPage(query: UnknownQuery = {}): UnknownPage {
|
|
return this.#records.listUnknownsPage(query);
|
|
}
|
|
|
|
verifyUnknownResolution(unknownId: string): Result<
|
|
{
|
|
readonly valid: boolean;
|
|
readonly truthVerified: boolean;
|
|
readonly unknown: ResidualUnknown;
|
|
},
|
|
UnknownRegistryError
|
|
> {
|
|
return this.#records.verifyUnknownResolution(unknownId);
|
|
}
|
|
}
|