mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 06:46:10 +00:00
refactor: remove cross-version investigation workflow
This commit is contained in:
1 parent
0d96275b68
commit
81996df7a5
42 files changed
+66
-3750
No files matched your search
@@ -159,19 +159,10 @@ type CapabilityAvailability =
|
||||
readonly availabilityCode?: ProviderRejectionCode;
|
||||
};
|
||||
|
||||
interface CapabilityLimits {
|
||||
readonly maxResults: number | null;
|
||||
readonly maxPayloadBytes: number | null;
|
||||
readonly timeoutMs: number | null;
|
||||
}
|
||||
|
||||
export type CapabilityDescriptor = CapabilityAvailability & {
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly operation: Exclude<AnalysisOperation, "health">;
|
||||
readonly pagination: "none" | "offset" | "cursor";
|
||||
readonly exhaustive: boolean;
|
||||
readonly effects: CapabilityEffects;
|
||||
readonly limits: CapabilityLimits;
|
||||
readonly limitations: readonly string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
UnknownStatus,
|
||||
UpdateUnknownInput,
|
||||
} from "../domain/residualUnknown.js";
|
||||
import type { InvestigationWorkspace } from "../domain/investigationWorkspace.js";
|
||||
import type { ReconstructionCoverageWorkspace } from "../domain/reconstructionCoverage.js";
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
@@ -58,14 +57,6 @@ export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
importAnalysisSnapshot(
|
||||
snapshot: AnalysisSnapshot,
|
||||
): Result<number, AnalysisError>;
|
||||
retainInvestigationWorkspace(
|
||||
workspace: InvestigationWorkspace,
|
||||
): "added" | "duplicate";
|
||||
investigationWorkspace(
|
||||
workspaceId: string,
|
||||
revision: number,
|
||||
): InvestigationWorkspace | undefined;
|
||||
investigationWorkspaces(): readonly InvestigationWorkspace[];
|
||||
retainReconstructionCoverageWorkspace(
|
||||
workspace: ReconstructionCoverageWorkspace,
|
||||
): "added" | "duplicate";
|
||||
|
||||
@@ -10,10 +10,6 @@ import {
|
||||
type AnalysisError,
|
||||
type UnknownRegistryError,
|
||||
} from "../domain/errors.js";
|
||||
import {
|
||||
parseInvestigationWorkspace,
|
||||
type InvestigationWorkspace,
|
||||
} from "../domain/investigationWorkspace.js";
|
||||
import {
|
||||
parseReconstructionCoverageWorkspace,
|
||||
type ReconstructionCoverageWorkspace,
|
||||
@@ -42,14 +38,13 @@ export interface ActiveAnalysisBinding {
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
}
|
||||
|
||||
/** Owns session evidence, snapshots, workspaces, and residual unknowns. */
|
||||
/** Owns session evidence, snapshots, and residual unknowns. */
|
||||
export abstract class BinarySessionRecords {
|
||||
readonly #evidence = new EvidenceLedger({
|
||||
maxRecords: 10_000,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
});
|
||||
readonly #snapshot = new AnalysisSnapshotCache();
|
||||
readonly #investigationWorkspaces = new Map<string, InvestigationWorkspace>();
|
||||
readonly #coverageWorkspaces = new Map<
|
||||
string,
|
||||
ReconstructionCoverageWorkspace
|
||||
@@ -216,30 +211,6 @@ export abstract class BinarySessionRecords {
|
||||
}
|
||||
}
|
||||
|
||||
retainInvestigationWorkspace(
|
||||
workspace: InvestigationWorkspace,
|
||||
): "added" | "duplicate" {
|
||||
const parsed = parseInvestigationWorkspace(workspace);
|
||||
const key = workspaceKey(parsed.workspace_id, parsed.revision);
|
||||
if (this.#investigationWorkspaces.has(key)) return "duplicate";
|
||||
this.#investigationWorkspaces.set(key, parsed);
|
||||
return "added";
|
||||
}
|
||||
|
||||
investigationWorkspace(
|
||||
workspaceId: string,
|
||||
revision: number,
|
||||
): InvestigationWorkspace | undefined {
|
||||
const workspace = this.#investigationWorkspaces.get(
|
||||
workspaceKey(workspaceId, revision),
|
||||
);
|
||||
return workspace === undefined ? undefined : structuredClone(workspace);
|
||||
}
|
||||
|
||||
investigationWorkspaces(): readonly InvestigationWorkspace[] {
|
||||
return sortedWorkspaces(this.#investigationWorkspaces.values());
|
||||
}
|
||||
|
||||
retainReconstructionCoverageWorkspace(
|
||||
workspace: ReconstructionCoverageWorkspace,
|
||||
): "added" | "duplicate" {
|
||||
|
||||
@@ -193,8 +193,6 @@ const capabilityStatus = (
|
||||
available: descriptor.available,
|
||||
reason: descriptor.reason,
|
||||
availability_code: descriptor.availabilityCode ?? null,
|
||||
pagination: descriptor.pagination,
|
||||
exhaustive: descriptor.exhaustive,
|
||||
effects: {
|
||||
mutates_artifact: descriptor.effects.mutatesArtifact,
|
||||
launches_process: descriptor.effects.launchesProcess,
|
||||
@@ -204,10 +202,5 @@ const capabilityStatus = (
|
||||
changes_permissions: descriptor.effects.changesPermissions,
|
||||
requires_root: descriptor.effects.requiresRoot,
|
||||
},
|
||||
limits: {
|
||||
max_results: descriptor.limits.maxResults,
|
||||
max_payload_bytes: descriptor.limits.maxPayloadBytes,
|
||||
timeout_ms: descriptor.limits.timeoutMs,
|
||||
},
|
||||
limitations: [...descriptor.limitations],
|
||||
});
|
||||
@@ -34,8 +34,6 @@ const status = (
|
||||
: (capability.availability_code ?? null),
|
||||
input_contract_version: 1,
|
||||
output_contract_version: 1,
|
||||
pagination: "none" as const,
|
||||
exhaustive: true,
|
||||
effects: {
|
||||
mutates_artifact: false,
|
||||
launches_process: false,
|
||||
@@ -45,11 +43,6 @@ const status = (
|
||||
changes_permissions: false,
|
||||
requires_root: false,
|
||||
},
|
||||
limits: {
|
||||
max_results: null,
|
||||
max_payload_bytes: null,
|
||||
timeout_ms: null,
|
||||
},
|
||||
limitations: [],
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import type { ArtifactLimits } from "../artifacts/ArtifactReader.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
ArtifactOperationError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type {
|
||||
CrossVersionInvestigationInput,
|
||||
InvestigationRunOptions,
|
||||
InvestigationRunTarget,
|
||||
} from "../domain/investigationWorkspace.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
paginateArtifactInventory,
|
||||
type ArtifactIntegrityPolicy,
|
||||
type ArtifactInventorySnapshot,
|
||||
} from "./ArtifactInventory.js";
|
||||
import { scanAuthorizedArtifactInventory } from "./AuthorizedArtifactInventory.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js";
|
||||
import { resolveArtifactIntegrityPolicy } from "./ArtifactInventory/policy.js";
|
||||
|
||||
export const AUTOMATIC_RUN_LIMITATION =
|
||||
"Automatic cross-version runs compare shipped artifact structure only; they do not execute either version.";
|
||||
|
||||
export interface VersionSnapshots {
|
||||
readonly left: ArtifactInventorySnapshot;
|
||||
readonly right: ArtifactInventorySnapshot;
|
||||
}
|
||||
|
||||
export interface InventoryEvidencePages {
|
||||
readonly left: readonly Evidence[];
|
||||
readonly right: readonly Evidence[];
|
||||
}
|
||||
|
||||
/** Injectable artifact scan boundary used by paired version inventory. */
|
||||
export type VersionInventoryScanner = typeof scanAuthorizedArtifactInventory;
|
||||
|
||||
/** Inputs shared by the two scans in one version comparison. */
|
||||
export interface VersionInventoryScanOptions {
|
||||
readonly leftPath: string;
|
||||
readonly rightPath: string;
|
||||
readonly limits: ArtifactLimits;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly integrity: ArtifactIntegrityPolicy;
|
||||
}
|
||||
|
||||
/** Start both independent version scans while preserving left/right identity. */
|
||||
export const scanVersionInventories = async (
|
||||
options: VersionInventoryScanOptions,
|
||||
scanner: VersionInventoryScanner = scanAuthorizedArtifactInventory,
|
||||
): Promise<VersionSnapshots> => {
|
||||
const [left, right] = await Promise.all([
|
||||
scanner(options.leftPath, options.limits, {
|
||||
signal: options.signal,
|
||||
integrity: options.integrity,
|
||||
}),
|
||||
scanner(options.rightPath, options.limits, {
|
||||
signal: options.signal,
|
||||
integrity: options.integrity,
|
||||
}),
|
||||
]);
|
||||
return { left, right };
|
||||
};
|
||||
|
||||
/** Authorize and scan both inputs once under the same bounded traversal policy. */
|
||||
export const scanVersions = async (
|
||||
input: CrossVersionInvestigationInput,
|
||||
signal?: AbortSignal,
|
||||
integrityContinueEnabled = false,
|
||||
): Promise<Result<VersionSnapshots, AnalysisError>> => {
|
||||
try {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("find_changed_behavior"));
|
||||
const limits = artifactLimits(input.options);
|
||||
const integrity = resolveArtifactIntegrityPolicy(
|
||||
input.integrity_policy === "fail"
|
||||
? { mode: "fail" }
|
||||
: {
|
||||
mode: input.integrity_policy,
|
||||
maxMismatches: input.max_integrity_mismatches,
|
||||
},
|
||||
integrityContinueEnabled,
|
||||
);
|
||||
return ok(
|
||||
await scanVersionInventories({
|
||||
leftPath: input.left_path,
|
||||
rightPath: input.right_path,
|
||||
limits,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
integrity,
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return err(
|
||||
new ArtifactOperationError(
|
||||
"inventory_artifact",
|
||||
cause.reason,
|
||||
cause.details,
|
||||
),
|
||||
);
|
||||
return err(new ArtifactOperationError("inventory_artifact", "io"));
|
||||
}
|
||||
};
|
||||
|
||||
/** Project complete snapshots into bounded, comparable Evidence page sets. */
|
||||
export const createInventoryEvidencePages = (
|
||||
input: CrossVersionInvestigationInput,
|
||||
snapshots: VersionSnapshots,
|
||||
): Result<InventoryEvidencePages, AnalysisError> => {
|
||||
const left = inventoryPages(input.left_path, snapshots.left, input);
|
||||
const right = inventoryPages(input.right_path, snapshots.right, input);
|
||||
if (left.length > 100 || right.length > 100)
|
||||
return err(new ArtifactOperationError("inventory_artifact", "limit"));
|
||||
return ok({ left, right });
|
||||
};
|
||||
|
||||
/** Reduce a snapshot to the content commitments used by run identity. */
|
||||
export const targetFor = (
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
): InvestigationRunTarget => ({
|
||||
root_sha256: snapshot.manifest.root_sha256,
|
||||
graph_sha256: snapshot.manifest.graph_sha256,
|
||||
manifest_id: snapshot.manifest.manifest_id,
|
||||
format: snapshot.manifest.root_format,
|
||||
});
|
||||
|
||||
/** Preserve scan and execution limitations in stable order. */
|
||||
export const runLimitations = (snapshots: VersionSnapshots): string[] =>
|
||||
[
|
||||
...new Set([
|
||||
...snapshots.left.limitations.map((item) => `Left: ${item}`),
|
||||
...snapshots.right.limitations.map((item) => `Right: ${item}`),
|
||||
AUTOMATIC_RUN_LIMITATION,
|
||||
]),
|
||||
].sort((left, right) => left.localeCompare(right));
|
||||
|
||||
const inventoryPages = (
|
||||
path: string,
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
input: CrossVersionInvestigationInput,
|
||||
): Evidence[] => {
|
||||
const options = input.options;
|
||||
const count = Math.max(
|
||||
1,
|
||||
Math.ceil(snapshot.nodes.length / options.page_size),
|
||||
Math.ceil(snapshot.occurrences.length / options.page_size),
|
||||
Math.ceil(snapshot.edges.length / options.page_size),
|
||||
);
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
inventoryPage(path, snapshot, input, index * options.page_size),
|
||||
);
|
||||
};
|
||||
|
||||
const inventoryPage = (
|
||||
path: string,
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
input: CrossVersionInvestigationInput,
|
||||
offset: number,
|
||||
): Evidence => {
|
||||
const options = input.options;
|
||||
const result = paginateArtifactInventory(snapshot, {
|
||||
nodeOffset: offset,
|
||||
nodeLimit: options.page_size,
|
||||
occurrenceOffset: offset,
|
||||
occurrenceLimit: options.page_size,
|
||||
edgeOffset: offset,
|
||||
edgeLimit: options.page_size,
|
||||
});
|
||||
return createEvidence(
|
||||
{
|
||||
path,
|
||||
sha256: snapshot.manifest.root_sha256,
|
||||
format: snapshot.manifest.root_format,
|
||||
},
|
||||
ARTIFACT_GRAPH_PROVIDER,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {
|
||||
node_offset: offset,
|
||||
node_limit: options.page_size,
|
||||
occurrence_offset: offset,
|
||||
occurrence_limit: options.page_size,
|
||||
edge_offset: offset,
|
||||
edge_limit: options.page_size,
|
||||
max_entries: options.max_entries,
|
||||
max_total_bytes: options.max_total_bytes,
|
||||
max_entry_bytes: options.max_entry_bytes,
|
||||
max_compression_ratio: options.max_compression_ratio,
|
||||
max_depth: options.max_depth,
|
||||
max_path_bytes: options.max_path_bytes,
|
||||
integrity_policy: input.integrity_policy,
|
||||
integrity_continue_approved: input.integrity_continue_approved,
|
||||
max_integrity_mismatches: input.max_integrity_mismatches,
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
limitations: result.limitations,
|
||||
locations: result.occurrences.items.map(
|
||||
({ logical_path: logicalPath }) => ({
|
||||
kind: "artifact-path" as const,
|
||||
path: logicalPath,
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const artifactLimits = (options: InvestigationRunOptions): ArtifactLimits => ({
|
||||
maxEntries: options.max_entries,
|
||||
maxTotalBytes: options.max_total_bytes,
|
||||
maxEntryBytes: options.max_entry_bytes,
|
||||
maxCompressionRatio: options.max_compression_ratio,
|
||||
maxDepth: options.max_depth,
|
||||
maxPathBytes: options.max_path_bytes,
|
||||
});
|
||||
@@ -1,498 +0,0 @@
|
||||
import { type Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
type EvidenceFilePolicy,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
EvidenceIntegrityError,
|
||||
InvestigationWorkspaceError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import {
|
||||
createLegacyInvestigationRunIdentity,
|
||||
createInvestigationRunIdentity,
|
||||
createInvestigationWorkspace,
|
||||
crossVersionInvestigationInputSchema,
|
||||
investigationRunSchema,
|
||||
reviseInvestigationWorkspace,
|
||||
type CrossVersionInvestigationInput,
|
||||
type InvestigationRun,
|
||||
type InvestigationWorkspace,
|
||||
} from "../domain/investigationWorkspace.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { BinarySessionPort } from "./BinarySession.js";
|
||||
import type { ProgressReporter } from "./ProgressReporter.js";
|
||||
import {
|
||||
createInventoryEvidencePages,
|
||||
runLimitations,
|
||||
scanVersions,
|
||||
targetFor,
|
||||
type InventoryEvidencePages,
|
||||
type VersionSnapshots,
|
||||
} from "./CrossVersionInventory.js";
|
||||
import {
|
||||
readInvestigationWorkspace,
|
||||
writeInvestigationWorkspace,
|
||||
} from "./InvestigationWorkspaceStore.js";
|
||||
import {
|
||||
createArtifactComparisonEvidence,
|
||||
createChangedBehaviorEvidence,
|
||||
} from "./CrossVersionInvestigationEvidence.js";
|
||||
import { selectCompletedInvestigationReplay } from "./CrossVersionInvestigationReplay.js";
|
||||
import {
|
||||
investigationEvidenceIds as evidenceIds,
|
||||
mergeInvestigationEvidence as mergeEvidence,
|
||||
recordsForInvestigationRun as recordsForRun,
|
||||
replaceInvestigationRun as replaceRun,
|
||||
} from "./CrossVersionInvestigationCollections.js";
|
||||
|
||||
export interface CrossVersionInvestigationOutcome {
|
||||
readonly evidence: Evidence;
|
||||
readonly workspace: InvestigationWorkspace;
|
||||
readonly reused: boolean;
|
||||
}
|
||||
|
||||
/** Runtime authority and cancellation capabilities for one investigation. */
|
||||
export interface CrossVersionInvestigationExecution {
|
||||
readonly session?: BinarySessionPort;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly progress?: ProgressReporter;
|
||||
readonly integrityContinueEnabled?: boolean;
|
||||
readonly authorizeWorkspaceWrite?: () => Promise<Result<null, AnalysisError>>;
|
||||
}
|
||||
|
||||
/** Run or resume a deterministic cross-version artifact investigation. */
|
||||
export const runCrossVersionInvestigation = async (
|
||||
input: unknown,
|
||||
policy: EvidenceFilePolicy,
|
||||
execution: CrossVersionInvestigationExecution,
|
||||
): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
|
||||
const parsed = crossVersionInvestigationInputSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
new AnalysisInputError("find_changed_behavior", {
|
||||
cause: parsed.error,
|
||||
}),
|
||||
);
|
||||
return runCrossVersionInvestigationValidated(parsed.data, policy, execution);
|
||||
};
|
||||
|
||||
/** Run input already parsed by a trusted adapter boundary. */
|
||||
export const runCrossVersionInvestigationValidated = async (
|
||||
input: CrossVersionInvestigationInput,
|
||||
policy: EvidenceFilePolicy,
|
||||
execution: CrossVersionInvestigationExecution,
|
||||
): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
|
||||
const initial = await readInvestigationWorkspace(
|
||||
input.workspace_path,
|
||||
policy,
|
||||
);
|
||||
if (!initial.ok) return initial;
|
||||
const preflight = validateWorkspaceRequest(initial.value, input);
|
||||
if (!preflight.ok) return preflight;
|
||||
if (isCancelled(execution.signal))
|
||||
return err(new AnalysisCancelledError("find_changed_behavior"));
|
||||
if (input.replay_run_id !== undefined) {
|
||||
const replay = selectCompletedInvestigationReplay(initial.value, input);
|
||||
if (!replay.ok) return replay;
|
||||
return completeOutcome(
|
||||
replay.value.workspace,
|
||||
replay.value.run,
|
||||
true,
|
||||
execution.session,
|
||||
);
|
||||
}
|
||||
await execution.progress?.report({
|
||||
phase: "scan_versions",
|
||||
completed: 0,
|
||||
total: 4,
|
||||
message: "Scanning both version inputs",
|
||||
});
|
||||
|
||||
const snapshots = await scanVersions(
|
||||
input,
|
||||
execution.signal,
|
||||
execution.integrityContinueEnabled,
|
||||
);
|
||||
if (!snapshots.ok) return snapshots;
|
||||
await execution.progress?.report({
|
||||
phase: "scan_versions",
|
||||
completed: 1,
|
||||
total: 4,
|
||||
message: "Version inputs scanned",
|
||||
});
|
||||
return continueInvestigation({
|
||||
input,
|
||||
initial: initial.value,
|
||||
snapshots: snapshots.value,
|
||||
policy,
|
||||
session: execution.session,
|
||||
signal: execution.signal,
|
||||
progress: execution.progress,
|
||||
authorizeWorkspaceWrite: execution.authorizeWorkspaceWrite,
|
||||
});
|
||||
};
|
||||
|
||||
interface RunState {
|
||||
readonly workspace: InvestigationWorkspace;
|
||||
readonly run: InvestigationRun;
|
||||
}
|
||||
|
||||
const continueInvestigation = async (context: {
|
||||
readonly input: CrossVersionInvestigationInput;
|
||||
readonly initial: InvestigationWorkspace | null;
|
||||
readonly snapshots: VersionSnapshots;
|
||||
readonly policy: EvidenceFilePolicy;
|
||||
readonly session: BinarySessionPort | undefined;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly progress: ProgressReporter | undefined;
|
||||
readonly authorizeWorkspaceWrite:
|
||||
| (() => Promise<Result<null, AnalysisError>>)
|
||||
| undefined;
|
||||
}): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
|
||||
const left = targetFor(context.snapshots.left);
|
||||
const right = targetFor(context.snapshots.right);
|
||||
const identity = createInvestigationRunIdentity({
|
||||
left,
|
||||
right,
|
||||
options: context.input.options,
|
||||
integrity_policy: context.input.integrity_policy,
|
||||
integrity_continue_approved: context.input.integrity_continue_approved,
|
||||
max_integrity_mismatches: context.input.max_integrity_mismatches,
|
||||
});
|
||||
const legacyIdentity = createLegacyInvestigationRunIdentity({
|
||||
left,
|
||||
right,
|
||||
options: context.input.options,
|
||||
});
|
||||
const existing = context.initial?.runs.find(
|
||||
(run) =>
|
||||
run.run_id === identity.runId ||
|
||||
(context.input.integrity_policy === "fail" &&
|
||||
!context.input.integrity_continue_approved &&
|
||||
context.input.max_integrity_mismatches === 10 &&
|
||||
"legacy_request_identity" in run &&
|
||||
run.max_integrity_mismatches === 10 &&
|
||||
run.run_id === legacyIdentity.runId),
|
||||
);
|
||||
if (context.initial !== null && existing?.status === "complete")
|
||||
return completeOutcome(context.initial, existing, true, context.session);
|
||||
if (context.authorizeWorkspaceWrite !== undefined) {
|
||||
const authorized = await context.authorizeWorkspaceWrite();
|
||||
if (!authorized.ok) return authorized;
|
||||
}
|
||||
const pages = createInventoryEvidencePages(context.input, context.snapshots);
|
||||
if (!pages.ok) return pages;
|
||||
if (isCancelled(context.signal))
|
||||
return err(new AnalysisCancelledError("find_changed_behavior"));
|
||||
const inventoried = await ensureInventoryCheckpoint({
|
||||
input: context.input,
|
||||
current: context.initial,
|
||||
existing,
|
||||
pages: pages.value,
|
||||
snapshots: context.snapshots,
|
||||
policy: context.policy,
|
||||
});
|
||||
if (!inventoried.ok) return inventoried;
|
||||
await context.progress?.report({
|
||||
phase: "inventory_versions",
|
||||
completed: 2,
|
||||
total: 4,
|
||||
message: "Both artifact inventories checkpointed",
|
||||
});
|
||||
if (isCancelled(context.signal))
|
||||
return err(new AnalysisCancelledError("find_changed_behavior"));
|
||||
const compared = await ensureComparisonCheckpoint(
|
||||
context.input,
|
||||
inventoried.value,
|
||||
context.policy,
|
||||
);
|
||||
if (!compared.ok) return compared;
|
||||
await context.progress?.report({
|
||||
phase: "compare_artifacts",
|
||||
completed: 3,
|
||||
total: 4,
|
||||
message: "Artifact comparison checkpointed",
|
||||
});
|
||||
if (isCancelled(context.signal))
|
||||
return err(new AnalysisCancelledError("find_changed_behavior"));
|
||||
const finalized = await finalizeInvestigation(
|
||||
context.input,
|
||||
compared.value,
|
||||
context.policy,
|
||||
context.session,
|
||||
);
|
||||
await context.progress?.report({
|
||||
phase: "changed_behavior",
|
||||
completed: 4,
|
||||
total: 4,
|
||||
message: finalized.ok ? "Investigation completed" : "Investigation failed",
|
||||
terminal: true,
|
||||
});
|
||||
return finalized;
|
||||
};
|
||||
|
||||
const isCancelled = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
const ensureInventoryCheckpoint = async (input: {
|
||||
readonly input: CrossVersionInvestigationInput;
|
||||
readonly current: InvestigationWorkspace | null;
|
||||
readonly existing: InvestigationRun | undefined;
|
||||
readonly pages: InventoryEvidencePages;
|
||||
readonly snapshots: VersionSnapshots;
|
||||
readonly policy: EvidenceFilePolicy;
|
||||
}): Promise<Result<RunState, AnalysisError>> => {
|
||||
if (input.existing !== undefined) {
|
||||
const consistent = validateInventoryCheckpoint(input.existing, input.pages);
|
||||
if (!consistent.ok) return consistent;
|
||||
return input.current === null
|
||||
? err(new EvidenceIntegrityError("Investigation workspace is missing"))
|
||||
: ok({ workspace: input.current, run: input.existing });
|
||||
}
|
||||
const left = targetFor(input.snapshots.left);
|
||||
const right = targetFor(input.snapshots.right);
|
||||
const identity = createInvestigationRunIdentity({
|
||||
left,
|
||||
right,
|
||||
options: input.input.options,
|
||||
integrity_policy: input.input.integrity_policy,
|
||||
integrity_continue_approved: input.input.integrity_continue_approved,
|
||||
max_integrity_mismatches: input.input.max_integrity_mismatches,
|
||||
});
|
||||
const run = investigationRunSchema.parse({
|
||||
schema_version: 1,
|
||||
run_id: identity.runId,
|
||||
request_sha256: identity.requestSha256,
|
||||
left,
|
||||
right,
|
||||
options: input.input.options,
|
||||
integrity_policy: input.input.integrity_policy,
|
||||
integrity_continue_approved: input.input.integrity_continue_approved,
|
||||
max_integrity_mismatches: input.input.max_integrity_mismatches,
|
||||
status: "running",
|
||||
completed_stages: ["inventory_left", "inventory_right"],
|
||||
left_inventory_evidence_ids: evidenceIds(input.pages.left),
|
||||
right_inventory_evidence_ids: evidenceIds(input.pages.right),
|
||||
comparison_evidence_id: null,
|
||||
result_evidence_id: null,
|
||||
limitations: runLimitations(input.snapshots),
|
||||
});
|
||||
const saved = await checkpoint({
|
||||
path: input.input.workspace_path,
|
||||
name: input.input.workspace_name,
|
||||
current: input.current,
|
||||
records: [...input.pages.left, ...input.pages.right],
|
||||
run,
|
||||
policy: input.policy,
|
||||
});
|
||||
return saved.ok ? ok({ workspace: saved.value, run }) : saved;
|
||||
};
|
||||
|
||||
const ensureComparisonCheckpoint = async (
|
||||
input: CrossVersionInvestigationInput,
|
||||
state: RunState,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<Result<RunState, AnalysisError>> => {
|
||||
if (state.run.comparison_evidence_id !== null) return ok(state);
|
||||
const inventory = inventoryEvidence(state.workspace, state.run);
|
||||
if (!inventory.ok) return inventory;
|
||||
const evidence = createArtifactComparisonEvidence(
|
||||
inventory.value,
|
||||
input.options.change_limit,
|
||||
);
|
||||
const run = investigationRunSchema.parse({
|
||||
...state.run,
|
||||
completed_stages: [
|
||||
"inventory_left",
|
||||
"inventory_right",
|
||||
"compare_artifacts",
|
||||
],
|
||||
comparison_evidence_id: evidence.evidence_id,
|
||||
});
|
||||
const saved = await checkpoint({
|
||||
path: input.workspace_path,
|
||||
name: input.workspace_name,
|
||||
current: state.workspace,
|
||||
records: [evidence],
|
||||
run,
|
||||
policy,
|
||||
});
|
||||
return saved.ok ? ok({ workspace: saved.value, run }) : saved;
|
||||
};
|
||||
|
||||
const finalizeInvestigation = async (
|
||||
input: CrossVersionInvestigationInput,
|
||||
state: RunState,
|
||||
policy: EvidenceFilePolicy,
|
||||
session?: BinarySessionPort,
|
||||
): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
|
||||
const comparison = comparisonEvidenceFor(state.workspace, state.run);
|
||||
if (!comparison.ok) return comparison;
|
||||
const evidence = createChangedBehaviorEvidence(
|
||||
state.workspace,
|
||||
state.run,
|
||||
comparison.value,
|
||||
);
|
||||
const run = investigationRunSchema.parse({
|
||||
...state.run,
|
||||
status: "complete",
|
||||
completed_stages: [
|
||||
"inventory_left",
|
||||
"inventory_right",
|
||||
"compare_artifacts",
|
||||
"find_changed_behavior",
|
||||
],
|
||||
result_evidence_id: evidence.evidence_id,
|
||||
});
|
||||
const saved = await checkpoint({
|
||||
path: input.workspace_path,
|
||||
name: input.workspace_name,
|
||||
current: state.workspace,
|
||||
records: [evidence],
|
||||
run,
|
||||
policy,
|
||||
});
|
||||
return saved.ok ? completeOutcome(saved.value, run, false, session) : saved;
|
||||
};
|
||||
|
||||
const comparisonEvidenceFor = (
|
||||
workspace: InvestigationWorkspace,
|
||||
run: InvestigationRun,
|
||||
): Result<Evidence, EvidenceIntegrityError> => {
|
||||
const evidence = workspace.bundle.records.find(
|
||||
({ evidence_id: id }) => id === run.comparison_evidence_id,
|
||||
);
|
||||
return evidence === undefined
|
||||
? err(
|
||||
new EvidenceIntegrityError(
|
||||
"Investigation comparison checkpoint is missing Evidence",
|
||||
),
|
||||
)
|
||||
: ok(evidence);
|
||||
};
|
||||
|
||||
const checkpoint = async (input: {
|
||||
readonly path: string;
|
||||
readonly name: string;
|
||||
readonly current: InvestigationWorkspace | null;
|
||||
readonly records: readonly Evidence[];
|
||||
readonly run: InvestigationRun;
|
||||
readonly policy: EvidenceFilePolicy;
|
||||
}): Promise<Result<InvestigationWorkspace, AnalysisError>> => {
|
||||
const records = mergeEvidence(
|
||||
input.current?.bundle.records ?? [],
|
||||
input.records,
|
||||
);
|
||||
const bundle = createEvidenceBundle(
|
||||
records,
|
||||
input.current?.bundle.unknowns ?? [],
|
||||
);
|
||||
const runs = replaceRun(input.current?.runs ?? [], input.run);
|
||||
const next =
|
||||
input.current === null
|
||||
? createInvestigationWorkspace(input.name, bundle, runs)
|
||||
: reviseInvestigationWorkspace(input.current, bundle, runs);
|
||||
const written = await writeInvestigationWorkspace(
|
||||
next,
|
||||
input.path,
|
||||
input.current?.revision ?? null,
|
||||
input.policy,
|
||||
);
|
||||
return written.ok ? ok(next) : written;
|
||||
};
|
||||
|
||||
const completeOutcome = (
|
||||
workspace: InvestigationWorkspace,
|
||||
run: InvestigationRun,
|
||||
reused: boolean,
|
||||
session?: BinarySessionPort,
|
||||
): Result<CrossVersionInvestigationOutcome, AnalysisError> => {
|
||||
if (run.result_evidence_id === null)
|
||||
return err(
|
||||
new EvidenceIntegrityError("Completed investigation has no result"),
|
||||
);
|
||||
const evidence = workspace.bundle.records.find(
|
||||
({ evidence_id: id }) => id === run.result_evidence_id,
|
||||
);
|
||||
if (evidence === undefined)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Completed investigation result Evidence is missing",
|
||||
),
|
||||
);
|
||||
if (session !== undefined) {
|
||||
const records = recordsForRun(workspace, run);
|
||||
const imported = session.importEvidenceBundle(
|
||||
createEvidenceBundle(records),
|
||||
);
|
||||
if (!imported.ok) return imported;
|
||||
}
|
||||
return ok({ evidence, workspace, reused });
|
||||
};
|
||||
|
||||
const validateWorkspaceRequest = (
|
||||
workspace: InvestigationWorkspace | null,
|
||||
input: CrossVersionInvestigationInput,
|
||||
): Result<null, InvestigationWorkspaceError> => {
|
||||
if (workspace !== null && workspace.name !== input.workspace_name)
|
||||
return err(new InvestigationWorkspaceError("update", "name-conflict"));
|
||||
if (
|
||||
input.expected_workspace_revision !== undefined &&
|
||||
workspace?.revision !== input.expected_workspace_revision
|
||||
)
|
||||
return err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
||||
return ok(null);
|
||||
};
|
||||
|
||||
const validateInventoryCheckpoint = (
|
||||
run: InvestigationRun,
|
||||
pages: {
|
||||
readonly left: readonly Evidence[];
|
||||
readonly right: readonly Evidence[];
|
||||
},
|
||||
): Result<null, EvidenceIntegrityError> =>
|
||||
JSON.stringify(run.left_inventory_evidence_ids) ===
|
||||
JSON.stringify(evidenceIds(pages.left)) &&
|
||||
JSON.stringify(run.right_inventory_evidence_ids) ===
|
||||
JSON.stringify(evidenceIds(pages.right))
|
||||
? ok(null)
|
||||
: err(
|
||||
new EvidenceIntegrityError(
|
||||
"Investigation inventory checkpoint disagrees with current content",
|
||||
),
|
||||
);
|
||||
|
||||
const inventoryEvidence = (
|
||||
workspace: InvestigationWorkspace | null,
|
||||
run: InvestigationRun,
|
||||
): Result<
|
||||
{ readonly left: readonly Evidence[]; readonly right: readonly Evidence[] },
|
||||
EvidenceIntegrityError
|
||||
> => {
|
||||
if (workspace === null)
|
||||
return err(
|
||||
new EvidenceIntegrityError("Investigation workspace is missing"),
|
||||
);
|
||||
const byId = new Map(
|
||||
workspace.bundle.records.map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
const left = run.left_inventory_evidence_ids.flatMap((id) => {
|
||||
const evidence = byId.get(id);
|
||||
return evidence === undefined ? [] : [evidence];
|
||||
});
|
||||
const right = run.right_inventory_evidence_ids.flatMap((id) => {
|
||||
const evidence = byId.get(id);
|
||||
return evidence === undefined ? [] : [evidence];
|
||||
});
|
||||
return left.length === run.left_inventory_evidence_ids.length &&
|
||||
right.length === run.right_inventory_evidence_ids.length
|
||||
? ok({ left, right })
|
||||
: err(
|
||||
new EvidenceIntegrityError(
|
||||
"Investigation inventory checkpoint is incomplete",
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type {
|
||||
InvestigationRun,
|
||||
InvestigationWorkspace,
|
||||
} from "../domain/investigationWorkspace.js";
|
||||
|
||||
export const recordsForInvestigationRun = (
|
||||
workspace: InvestigationWorkspace,
|
||||
run: InvestigationRun,
|
||||
): Evidence[] => {
|
||||
const ids = new Set([
|
||||
...run.left_inventory_evidence_ids,
|
||||
...run.right_inventory_evidence_ids,
|
||||
...(run.comparison_evidence_id === null
|
||||
? []
|
||||
: [run.comparison_evidence_id]),
|
||||
...(run.result_evidence_id === null ? [] : [run.result_evidence_id]),
|
||||
]);
|
||||
return workspace.bundle.records.filter(({ evidence_id: id }) => ids.has(id));
|
||||
};
|
||||
|
||||
export const mergeInvestigationEvidence = (
|
||||
current: readonly Evidence[],
|
||||
additions: readonly Evidence[],
|
||||
): Evidence[] => {
|
||||
const records = new Map(
|
||||
current.map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
for (const record of additions)
|
||||
if (!records.has(record.evidence_id))
|
||||
records.set(record.evidence_id, record);
|
||||
return [...records.values()];
|
||||
};
|
||||
|
||||
export const replaceInvestigationRun = (
|
||||
runs: readonly InvestigationRun[],
|
||||
replacement: InvestigationRun,
|
||||
): InvestigationRun[] => [
|
||||
...runs.filter(({ run_id: id }) => id !== replacement.run_id),
|
||||
replacement,
|
||||
];
|
||||
|
||||
export const investigationEvidenceIds = (
|
||||
records: readonly Evidence[],
|
||||
): string[] => records.map(({ evidence_id: id }) => id);
|
||||
@@ -1,91 +0,0 @@
|
||||
import { compareArtifacts } from "../domain/artifactComparison.js";
|
||||
import { findChangedBehavior } from "../domain/changedBehavior.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
investigationRunSummarySchema,
|
||||
type InvestigationRun,
|
||||
type InvestigationWorkspace,
|
||||
} from "../domain/investigationWorkspace.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { AUTOMATIC_RUN_LIMITATION } from "./CrossVersionInventory.js";
|
||||
import {
|
||||
ARTIFACT_COMPARISON_PROVIDER,
|
||||
CHANGED_BEHAVIOR_PROVIDER,
|
||||
} from "./InvestigationProviders.js";
|
||||
|
||||
/** Derive deterministic artifact-comparison Evidence from inventory pages. */
|
||||
export const createArtifactComparisonEvidence = (
|
||||
inventory: {
|
||||
readonly left: readonly Evidence[];
|
||||
readonly right: readonly Evidence[];
|
||||
},
|
||||
limit: number,
|
||||
): Evidence => {
|
||||
const comparison = compareArtifacts(
|
||||
inventory.left,
|
||||
inventory.right,
|
||||
0,
|
||||
limit,
|
||||
);
|
||||
const leftIds = evidenceIds(inventory.left);
|
||||
const rightIds = evidenceIds(inventory.right);
|
||||
return createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {
|
||||
predicateType: "rea.artifact-comparison/v1",
|
||||
operation: "compare_artifacts",
|
||||
parameters: {
|
||||
left_evidence_ids: leftIds,
|
||||
right_evidence_ids: rightIds,
|
||||
offset: 0,
|
||||
limit,
|
||||
},
|
||||
result: jsonValueSchema.parse(comparison),
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations: comparison.limitations,
|
||||
evidenceLinks: [...leftIds, ...rightIds],
|
||||
});
|
||||
};
|
||||
|
||||
/** Derive the deterministic final report for one comparison checkpoint. */
|
||||
export const createChangedBehaviorEvidence = (
|
||||
workspace: InvestigationWorkspace,
|
||||
run: InvestigationRun,
|
||||
comparison: Evidence,
|
||||
): Evidence => {
|
||||
const limit = Math.min(run.options.change_limit, 100);
|
||||
const changed = findChangedBehavior([comparison], 0, limit);
|
||||
const summary = investigationRunSummarySchema.parse({
|
||||
schema_version: 1,
|
||||
workspace_id: workspace.workspace_id,
|
||||
run_id: run.run_id,
|
||||
left_manifest_id: run.left.manifest_id,
|
||||
right_manifest_id: run.right.manifest_id,
|
||||
inventory_evidence_count:
|
||||
run.left_inventory_evidence_ids.length +
|
||||
run.right_inventory_evidence_ids.length,
|
||||
comparison_evidence_id: comparison.evidence_id,
|
||||
limitations: run.limitations,
|
||||
});
|
||||
return createEvidence(undefined, CHANGED_BEHAVIOR_PROVIDER, {
|
||||
predicateType: "rea.changed-behavior/v1",
|
||||
operation: "find_changed_behavior",
|
||||
parameters: {
|
||||
workspace_id: workspace.workspace_id,
|
||||
run_id: run.run_id,
|
||||
comparison_evidence_ids: [comparison.evidence_id],
|
||||
offset: 0,
|
||||
limit,
|
||||
},
|
||||
result: jsonValueSchema.parse({
|
||||
...changed,
|
||||
investigation_run: summary,
|
||||
}),
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations: [...changed.limitations, AUTOMATIC_RUN_LIMITATION],
|
||||
evidenceLinks: changed.evidence_links,
|
||||
});
|
||||
};
|
||||
|
||||
const evidenceIds = (records: readonly Evidence[]): string[] =>
|
||||
records.map(({ evidence_id: id }) => id);
|
||||
@@ -1,341 +0,0 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import {
|
||||
artifactInventoryResultSchema,
|
||||
type ArtifactInventoryResult,
|
||||
} from "../domain/artifactGraph.js";
|
||||
import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
InvestigationWorkspaceError,
|
||||
} from "../domain/errors.js";
|
||||
import type {
|
||||
CrossVersionInvestigationInput,
|
||||
InvestigationRun,
|
||||
InvestigationRunTarget,
|
||||
InvestigationWorkspace,
|
||||
} from "../domain/investigationWorkspace.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
createArtifactComparisonEvidence,
|
||||
createChangedBehaviorEvidence,
|
||||
} from "./CrossVersionInvestigationEvidence.js";
|
||||
import { AUTOMATIC_RUN_LIMITATION } from "./CrossVersionInventory.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js";
|
||||
|
||||
/** Validated workspace state returned by an explicit cache-only selector. */
|
||||
export interface CompletedInvestigationReplay {
|
||||
readonly workspace: InvestigationWorkspace;
|
||||
readonly run: InvestigationRun;
|
||||
}
|
||||
|
||||
interface ValidatedInventory {
|
||||
readonly records: readonly Evidence[];
|
||||
readonly result: ReturnType<typeof parseArtifactInventoryEvidence>;
|
||||
}
|
||||
|
||||
/** Select and verify one caller-identified complete run without input access. */
|
||||
export const selectCompletedInvestigationReplay = (
|
||||
workspace: InvestigationWorkspace | null,
|
||||
input: CrossVersionInvestigationInput,
|
||||
): Result<
|
||||
CompletedInvestigationReplay,
|
||||
InvestigationWorkspaceError | EvidenceIntegrityError
|
||||
> => {
|
||||
const run = workspace?.runs.find(
|
||||
({ run_id: runId }) => runId === input.replay_run_id,
|
||||
);
|
||||
if (
|
||||
workspace === null ||
|
||||
run === undefined ||
|
||||
run.status !== "complete" ||
|
||||
!controlsMatch(run, input)
|
||||
)
|
||||
return replayConflict();
|
||||
const records = new Map(
|
||||
workspace.bundle.records.map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
const left = inventoryRecords(records, run.left_inventory_evidence_ids);
|
||||
const right = inventoryRecords(records, run.right_inventory_evidence_ids);
|
||||
if (!left.ok) return left;
|
||||
if (!right.ok) return right;
|
||||
if (
|
||||
!pathsMatch(left.value, input.left_path) ||
|
||||
!pathsMatch(right.value, input.right_path)
|
||||
)
|
||||
return replayConflict();
|
||||
const validated = validateLinkedEvidence(
|
||||
workspace,
|
||||
run,
|
||||
left.value,
|
||||
right.value,
|
||||
);
|
||||
return validated.ok ? ok({ workspace, run }) : validated;
|
||||
};
|
||||
|
||||
const controlsMatch = (
|
||||
run: InvestigationRun,
|
||||
input: CrossVersionInvestigationInput,
|
||||
): boolean =>
|
||||
isDeepStrictEqual(run.options, input.options) &&
|
||||
run.integrity_policy === input.integrity_policy &&
|
||||
run.integrity_continue_approved === input.integrity_continue_approved &&
|
||||
run.max_integrity_mismatches === input.max_integrity_mismatches;
|
||||
|
||||
const inventoryRecords = (
|
||||
records: ReadonlyMap<string, Evidence>,
|
||||
evidenceIds: readonly string[],
|
||||
): Result<readonly Evidence[], EvidenceIntegrityError> => {
|
||||
if (new Set(evidenceIds).size !== evidenceIds.length)
|
||||
return replayIntegrity("Investigation replay repeats inventory Evidence");
|
||||
const selected = evidenceIds.flatMap((evidenceId) => {
|
||||
const evidence = records.get(evidenceId);
|
||||
return evidence === undefined ? [] : [evidence];
|
||||
});
|
||||
return selected.length === evidenceIds.length
|
||||
? ok(selected)
|
||||
: replayIntegrity("Investigation replay is missing inventory Evidence");
|
||||
};
|
||||
|
||||
const pathsMatch = (records: readonly Evidence[], path: string): boolean =>
|
||||
records.every(({ subject }) => subject?.local_path === path);
|
||||
|
||||
const validateLinkedEvidence = (
|
||||
workspace: InvestigationWorkspace,
|
||||
run: InvestigationRun,
|
||||
leftRecords: readonly Evidence[],
|
||||
rightRecords: readonly Evidence[],
|
||||
): Result<null, EvidenceIntegrityError> => {
|
||||
try {
|
||||
const left = validateInventory(leftRecords, run.left, run);
|
||||
const right = validateInventory(rightRecords, run.right, run);
|
||||
if (!left.ok) return left;
|
||||
if (!right.ok) return right;
|
||||
const expectedLimitations = runLimitations(left.value, right.value);
|
||||
if (!isDeepStrictEqual(run.limitations, expectedLimitations))
|
||||
return replayIntegrity(
|
||||
"Investigation replay limitations disagree with inventory Evidence",
|
||||
);
|
||||
const comparison = createArtifactComparisonEvidence(
|
||||
{ left: left.value.records, right: right.value.records },
|
||||
run.options.change_limit,
|
||||
);
|
||||
const storedComparison = evidenceFor(workspace, run.comparison_evidence_id);
|
||||
if (
|
||||
storedComparison === undefined ||
|
||||
!isDeepStrictEqual(storedComparison, comparison)
|
||||
)
|
||||
return replayIntegrity(
|
||||
"Investigation replay comparison Evidence is inconsistent",
|
||||
);
|
||||
const result = createChangedBehaviorEvidence(workspace, run, comparison);
|
||||
const storedResult = evidenceFor(workspace, run.result_evidence_id);
|
||||
return storedResult !== undefined && isDeepStrictEqual(storedResult, result)
|
||||
? ok(null)
|
||||
: replayIntegrity("Investigation replay result Evidence is inconsistent");
|
||||
} catch (cause: unknown) {
|
||||
return replayIntegrity(
|
||||
"Investigation replay linked Evidence is invalid",
|
||||
cause,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateInventory = (
|
||||
records: readonly Evidence[],
|
||||
target: InvestigationRunTarget,
|
||||
run: InvestigationRun,
|
||||
): Result<ValidatedInventory, EvidenceIntegrityError> => {
|
||||
const pages = records.map((record) =>
|
||||
artifactInventoryResultSchema.parse(record.normalized_result),
|
||||
);
|
||||
const expectedPageCount = Math.max(
|
||||
1,
|
||||
Math.ceil((pages[0]?.manifest.node_count ?? 0) / run.options.page_size),
|
||||
Math.ceil(
|
||||
(pages[0]?.manifest.occurrence_count ?? 0) / run.options.page_size,
|
||||
),
|
||||
Math.ceil((pages[0]?.manifest.edge_count ?? 0) / run.options.page_size),
|
||||
);
|
||||
if (records.length !== expectedPageCount)
|
||||
return replayIntegrity(
|
||||
"Investigation replay inventory Evidence pagination is inconsistent",
|
||||
);
|
||||
if (!sameSnapshotMetadata(pages))
|
||||
return replayIntegrity(
|
||||
"Investigation replay inventory pages disagree on snapshot metadata",
|
||||
);
|
||||
for (const [index, record] of records.entries()) {
|
||||
const page = pages[index];
|
||||
if (page === undefined || !inventoryRecordMatches(record, page, run, index))
|
||||
return replayIntegrity(
|
||||
"Investigation replay inventory Evidence is inconsistent",
|
||||
);
|
||||
}
|
||||
const result = parseArtifactInventoryEvidence(records);
|
||||
if (!result.inventory.complete || !targetMatches(result.inventory, target))
|
||||
return replayIntegrity(
|
||||
"Investigation replay inventory commitment is inconsistent",
|
||||
);
|
||||
return ok({ records, result });
|
||||
};
|
||||
|
||||
const inventoryRecordMatches = (
|
||||
record: Evidence,
|
||||
page: ArtifactInventoryResult,
|
||||
run: InvestigationRun,
|
||||
index: number,
|
||||
): boolean => {
|
||||
const offset = index * run.options.page_size;
|
||||
const parameters = {
|
||||
node_offset: offset,
|
||||
node_limit: run.options.page_size,
|
||||
occurrence_offset: offset,
|
||||
occurrence_limit: run.options.page_size,
|
||||
edge_offset: offset,
|
||||
edge_limit: run.options.page_size,
|
||||
max_entries: run.options.max_entries,
|
||||
max_total_bytes: run.options.max_total_bytes,
|
||||
max_entry_bytes: run.options.max_entry_bytes,
|
||||
max_compression_ratio: run.options.max_compression_ratio,
|
||||
max_depth: run.options.max_depth,
|
||||
max_path_bytes: run.options.max_path_bytes,
|
||||
integrity_policy: run.integrity_policy,
|
||||
integrity_continue_approved: run.integrity_continue_approved,
|
||||
max_integrity_mismatches: run.max_integrity_mismatches,
|
||||
};
|
||||
const locations = page.occurrences.items.map(({ logical_path: path }) => ({
|
||||
kind: "artifact-path" as const,
|
||||
path,
|
||||
}));
|
||||
return (
|
||||
record.subject !== null &&
|
||||
record.subject.name === record.subject.local_path.split("/").at(-1) &&
|
||||
record.subject.digest.sha256 === page.manifest.root_sha256 &&
|
||||
record.subject.format === page.manifest.root_format &&
|
||||
record.subject.architecture === null &&
|
||||
isDeepStrictEqual(record.provider, ARTIFACT_GRAPH_PROVIDER) &&
|
||||
record.predicate_type === "rea.analysis/v2" &&
|
||||
record.operation === "inventory_artifact" &&
|
||||
isDeepStrictEqual(record.parameters, parameters) &&
|
||||
record.raw_result === null &&
|
||||
record.confidence === "observed" &&
|
||||
record.authority === "shipped-artifact" &&
|
||||
record.environment === null &&
|
||||
isDeepStrictEqual(record.limitations, page.limitations) &&
|
||||
isDeepStrictEqual(record.locations, locations) &&
|
||||
record.evidence_links.length === 0 &&
|
||||
isDeepStrictEqual(page.limits, traversalLimits(run)) &&
|
||||
pageMatches(
|
||||
page.nodes,
|
||||
offset,
|
||||
run.options.page_size,
|
||||
page.manifest.node_count,
|
||||
) &&
|
||||
pageMatches(
|
||||
page.occurrences,
|
||||
offset,
|
||||
run.options.page_size,
|
||||
page.manifest.occurrence_count,
|
||||
) &&
|
||||
pageMatches(
|
||||
page.edges,
|
||||
offset,
|
||||
run.options.page_size,
|
||||
page.manifest.edge_count,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const sameSnapshotMetadata = (
|
||||
pages: readonly ArtifactInventoryResult[],
|
||||
): boolean => {
|
||||
const first = pages[0];
|
||||
return (
|
||||
first !== undefined &&
|
||||
pages.every(
|
||||
(page) =>
|
||||
isDeepStrictEqual(page.manifest, first.manifest) &&
|
||||
isDeepStrictEqual(page.limits, first.limits) &&
|
||||
isDeepStrictEqual(page.provenance, first.provenance) &&
|
||||
isDeepStrictEqual(
|
||||
page.integrity_contradictions,
|
||||
first.integrity_contradictions,
|
||||
) &&
|
||||
isDeepStrictEqual(page.limitations, first.limitations),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const pageMatches = (
|
||||
page: {
|
||||
readonly items: readonly unknown[];
|
||||
readonly offset: number;
|
||||
readonly limit: number;
|
||||
readonly total: number;
|
||||
readonly next_offset: number | null;
|
||||
},
|
||||
offset: number,
|
||||
limit: number,
|
||||
total: number,
|
||||
): boolean =>
|
||||
page.offset === offset &&
|
||||
page.limit === limit &&
|
||||
page.total === total &&
|
||||
page.items.length === Math.max(0, Math.min(limit, page.total - offset)) &&
|
||||
page.next_offset === (offset + limit < page.total ? offset + limit : null);
|
||||
|
||||
const traversalLimits = (run: InvestigationRun) => ({
|
||||
max_entries: run.options.max_entries,
|
||||
max_total_bytes: run.options.max_total_bytes,
|
||||
max_entry_bytes: run.options.max_entry_bytes,
|
||||
max_compression_ratio: run.options.max_compression_ratio,
|
||||
max_depth: run.options.max_depth,
|
||||
max_path_bytes: run.options.max_path_bytes,
|
||||
});
|
||||
|
||||
const targetMatches = (
|
||||
inventory: ReturnType<typeof parseArtifactInventoryEvidence>["inventory"],
|
||||
target: InvestigationRunTarget,
|
||||
): boolean =>
|
||||
inventory.manifest.root_sha256 === target.root_sha256 &&
|
||||
inventory.manifest.graph_sha256 === target.graph_sha256 &&
|
||||
inventory.manifest.manifest_id === target.manifest_id &&
|
||||
inventory.manifest.root_format === target.format;
|
||||
|
||||
const runLimitations = (
|
||||
left: ValidatedInventory,
|
||||
right: ValidatedInventory,
|
||||
): string[] =>
|
||||
[
|
||||
...new Set([
|
||||
...left.result.inventory.limitations.map((item) => `Left: ${item}`),
|
||||
...right.result.inventory.limitations.map((item) => `Right: ${item}`),
|
||||
AUTOMATIC_RUN_LIMITATION,
|
||||
]),
|
||||
].sort((first, second) => first.localeCompare(second));
|
||||
|
||||
const evidenceFor = (
|
||||
workspace: InvestigationWorkspace,
|
||||
evidenceId: string | null,
|
||||
): Evidence | undefined =>
|
||||
evidenceId === null
|
||||
? undefined
|
||||
: workspace.bundle.records.find(
|
||||
({ evidence_id: current }) => current === evidenceId,
|
||||
);
|
||||
|
||||
const replayConflict = (): Result<never, InvestigationWorkspaceError> =>
|
||||
err(new InvestigationWorkspaceError("read", "revision-conflict"));
|
||||
|
||||
const replayIntegrity = (
|
||||
message: string,
|
||||
cause?: unknown,
|
||||
): Result<never, EvidenceIntegrityError> =>
|
||||
err(
|
||||
new EvidenceIntegrityError(
|
||||
message,
|
||||
cause === undefined ? undefined : { cause },
|
||||
),
|
||||
);
|
||||
@@ -39,7 +39,6 @@ const grantSchema = z.object({
|
||||
});
|
||||
|
||||
const storeSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
project_id: z.string().regex(/^project_[a-f0-9]{64}$/u),
|
||||
project_root: z.string(),
|
||||
grants: z.array(grantSchema).max(1_000),
|
||||
@@ -133,7 +132,6 @@ export const writeProjectPermissionStore = async (
|
||||
const project = await identifyPermissionProject(projectRoot);
|
||||
if (!project.ok) return project;
|
||||
const candidate = storeSchema.safeParse({
|
||||
schema_version: 1,
|
||||
project_id: project.value.id,
|
||||
project_root: project.value.root,
|
||||
grants,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InvestigationWorkspaceError } from "../domain/errors.js";
|
||||
import { WorkspaceStorageError } from "../domain/errors.js";
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
parseReconstructionCoverageWorkspace,
|
||||
@@ -10,29 +10,27 @@ import {
|
||||
readRevisionedWorkspace,
|
||||
writeRevisionedWorkspace,
|
||||
type RevisionedWorkspaceCodec,
|
||||
} from "./InvestigationWorkspaceStore.js";
|
||||
} from "./RevisionedWorkspaceStore.js";
|
||||
|
||||
const codec: RevisionedWorkspaceCodec<ReconstructionCoverageWorkspace> = {
|
||||
parse: parseReconstructionCoverageWorkspace,
|
||||
serialize: serializeReconstructionCoverageWorkspace,
|
||||
validateNext: (current, next, expectedRevision) => {
|
||||
if ((current?.revision ?? null) !== expectedRevision)
|
||||
return err(
|
||||
new InvestigationWorkspaceError("update", "revision-conflict"),
|
||||
);
|
||||
return err(new WorkspaceStorageError("update", "revision-conflict"));
|
||||
if (current === null)
|
||||
return next.revision === 1 && next.previous_revision_sha256 === null
|
||||
? ok(null)
|
||||
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
||||
: err(new WorkspaceStorageError("update", "revision-conflict"));
|
||||
if (
|
||||
current.name !== next.name ||
|
||||
current.workspace_id !== next.workspace_id
|
||||
)
|
||||
return err(new InvestigationWorkspaceError("update", "name-conflict"));
|
||||
return err(new WorkspaceStorageError("update", "name-conflict"));
|
||||
return next.revision === current.revision + 1 &&
|
||||
next.previous_revision_sha256 === current.revision_sha256
|
||||
? ok(null)
|
||||
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
||||
: err(new WorkspaceStorageError("update", "revision-conflict"));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -41,7 +39,7 @@ export const readReconstructionCoverageWorkspace = (
|
||||
path: string,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<
|
||||
Result<ReconstructionCoverageWorkspace | null, InvestigationWorkspaceError>
|
||||
Result<ReconstructionCoverageWorkspace | null, WorkspaceStorageError>
|
||||
> => readRevisionedWorkspace(path, policy, codec);
|
||||
|
||||
/** Atomically append one reconstruction coverage workspace CAS revision. */
|
||||
@@ -53,7 +51,7 @@ export const writeReconstructionCoverageWorkspace = (
|
||||
): Promise<
|
||||
Result<
|
||||
{ readonly path: string; readonly bytes: number },
|
||||
InvestigationWorkspaceError
|
||||
WorkspaceStorageError
|
||||
>
|
||||
> =>
|
||||
writeRevisionedWorkspace(path, policy, {
|
||||
|
||||
+18
-76
@@ -11,16 +11,11 @@ import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
|
||||
import { InvestigationWorkspaceError } from "../domain/errors.js";
|
||||
import {
|
||||
parseInvestigationWorkspace,
|
||||
serializeInvestigationWorkspace,
|
||||
type InvestigationWorkspace,
|
||||
} from "../domain/investigationWorkspace.js";
|
||||
import { WorkspaceStorageError } from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
|
||||
|
||||
type WorkspaceResult<Value> = Result<Value, InvestigationWorkspaceError>;
|
||||
type WorkspaceResult<Value> = Result<Value, WorkspaceStorageError>;
|
||||
|
||||
/** Parser, serializer, and CAS transition owned by one revisioned workspace. */
|
||||
export interface RevisionedWorkspaceCodec<Document> {
|
||||
@@ -33,20 +28,6 @@ export interface RevisionedWorkspaceCodec<Document> {
|
||||
) => WorkspaceResult<null>;
|
||||
}
|
||||
|
||||
const investigationWorkspaceCodec: RevisionedWorkspaceCodec<InvestigationWorkspace> =
|
||||
{
|
||||
parse: parseInvestigationWorkspace,
|
||||
serialize: serializeInvestigationWorkspace,
|
||||
validateNext: validateNextRevision,
|
||||
};
|
||||
|
||||
/** Read a validated workspace, returning null only when it does not exist. */
|
||||
export const readInvestigationWorkspace = async (
|
||||
path: string,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<WorkspaceResult<InvestigationWorkspace | null>> =>
|
||||
readRevisionedWorkspace(path, policy, investigationWorkspaceCodec);
|
||||
|
||||
/** Read one root-confined, owner-only revisioned workspace document. */
|
||||
export const readRevisionedWorkspace = async <Document>(
|
||||
path: string,
|
||||
@@ -54,14 +35,14 @@ export const readRevisionedWorkspace = async <Document>(
|
||||
codec: RevisionedWorkspaceCodec<Document>,
|
||||
): Promise<WorkspaceResult<Document | null>> => {
|
||||
if (policy.roots.length === 0)
|
||||
return err(new InvestigationWorkspaceError("read", "disabled"));
|
||||
return err(new WorkspaceStorageError("read", "disabled"));
|
||||
try {
|
||||
const destination = await resolveDestination(path, policy.roots);
|
||||
if (destination === null)
|
||||
return err(new InvestigationWorkspaceError("read", "outside-root"));
|
||||
return err(new WorkspaceStorageError("read", "outside-root"));
|
||||
return await readWorkspaceFile(destination, policy, codec);
|
||||
} catch (cause: unknown) {
|
||||
return err(new InvestigationWorkspaceError("read", "io", { cause }));
|
||||
return err(new WorkspaceStorageError("read", "io", { cause }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,21 +52,6 @@ export interface RevisionedWorkspaceWriteInput<Document> {
|
||||
readonly codec: RevisionedWorkspaceCodec<Document>;
|
||||
}
|
||||
|
||||
/** Atomically append one CAS-linked workspace revision under an exclusive lock. */
|
||||
export const writeInvestigationWorkspace = async (
|
||||
workspace: InvestigationWorkspace,
|
||||
path: string,
|
||||
expectedRevision: number | null,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<
|
||||
WorkspaceResult<{ readonly path: string; readonly bytes: number }>
|
||||
> =>
|
||||
writeRevisionedWorkspace(path, policy, {
|
||||
document: workspace,
|
||||
expectedRevision,
|
||||
codec: investigationWorkspaceCodec,
|
||||
});
|
||||
|
||||
/** Atomically append one validated CAS-linked revision under an exclusive lock. */
|
||||
export const writeRevisionedWorkspace = async <Document>(
|
||||
path: string,
|
||||
@@ -96,23 +62,21 @@ export const writeRevisionedWorkspace = async <Document>(
|
||||
> => {
|
||||
const { document, expectedRevision, codec } = input;
|
||||
if (policy.roots.length === 0)
|
||||
return err(new InvestigationWorkspaceError("update", "disabled"));
|
||||
return err(new WorkspaceStorageError("update", "disabled"));
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = codec.serialize(document);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new InvestigationWorkspaceError("update", "integrity", { cause }),
|
||||
);
|
||||
return err(new WorkspaceStorageError("update", "integrity", { cause }));
|
||||
}
|
||||
const bytes = Buffer.byteLength(encoded, "utf8");
|
||||
if (bytes > policy.maxBytes)
|
||||
return err(new InvestigationWorkspaceError("update", "too-large"));
|
||||
return err(new WorkspaceStorageError("update", "too-large"));
|
||||
let lock: StoreFileLock | undefined;
|
||||
try {
|
||||
const destination = await resolveDestination(path, policy.roots);
|
||||
if (destination === null)
|
||||
return err(new InvestigationWorkspaceError("update", "outside-root"));
|
||||
return err(new WorkspaceStorageError("update", "outside-root"));
|
||||
const acquired = await acquireLock(destination);
|
||||
if (!acquired.ok) return acquired;
|
||||
lock = acquired.value;
|
||||
@@ -131,7 +95,7 @@ export const writeRevisionedWorkspace = async <Document>(
|
||||
});
|
||||
return ok({ path: resolve(path), bytes });
|
||||
} catch (cause: unknown) {
|
||||
return err(new InvestigationWorkspaceError("update", "io", { cause }));
|
||||
return err(new WorkspaceStorageError("update", "io", { cause }));
|
||||
} finally {
|
||||
if (lock !== undefined) await lock.release();
|
||||
}
|
||||
@@ -163,26 +127,24 @@ const readWorkspaceFile = async <Document>(
|
||||
});
|
||||
if (stats === undefined) return ok(null);
|
||||
if (!stats.isFile() || stats.isSymbolicLink())
|
||||
return err(new InvestigationWorkspaceError("read", "not-file"));
|
||||
return err(new WorkspaceStorageError("read", "not-file"));
|
||||
if (stats.size > policy.maxBytes)
|
||||
return err(new InvestigationWorkspaceError("read", "too-large"));
|
||||
return err(new WorkspaceStorageError("read", "too-large"));
|
||||
const encoded = await readFile(destination);
|
||||
if (encoded.byteLength > policy.maxBytes)
|
||||
return err(new InvestigationWorkspaceError("read", "too-large"));
|
||||
return err(new WorkspaceStorageError("read", "too-large"));
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(encoded.toString("utf8"));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new InvestigationWorkspaceError("read", "invalid-json", { cause }),
|
||||
);
|
||||
return err(new WorkspaceStorageError("read", "invalid-json", { cause }));
|
||||
}
|
||||
if (!isJsonWithinLimits(decoded, policy))
|
||||
return err(new InvestigationWorkspaceError("read", "too-large"));
|
||||
return err(new WorkspaceStorageError("read", "too-large"));
|
||||
try {
|
||||
return ok(codec.parse(decoded));
|
||||
} catch (cause: unknown) {
|
||||
return err(new InvestigationWorkspaceError("read", "integrity", { cause }));
|
||||
return err(new WorkspaceStorageError("read", "integrity", { cause }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,7 +160,7 @@ const acquireLock = async (
|
||||
return ok(await createStoreFileLock(path));
|
||||
} catch (retryCause: unknown) {
|
||||
return err(
|
||||
new InvestigationWorkspaceError(
|
||||
new WorkspaceStorageError(
|
||||
"update",
|
||||
isAlreadyExists(retryCause) ? "locked" : "io",
|
||||
{ cause: retryCause },
|
||||
@@ -207,7 +169,7 @@ const acquireLock = async (
|
||||
}
|
||||
}
|
||||
return err(
|
||||
new InvestigationWorkspaceError(
|
||||
new WorkspaceStorageError(
|
||||
"update",
|
||||
isAlreadyExists(cause) ? "locked" : "io",
|
||||
{ cause },
|
||||
@@ -216,26 +178,6 @@ const acquireLock = async (
|
||||
}
|
||||
};
|
||||
|
||||
function validateNextRevision(
|
||||
current: InvestigationWorkspace | null,
|
||||
next: InvestigationWorkspace,
|
||||
expectedRevision: number | null,
|
||||
): WorkspaceResult<null> {
|
||||
if ((current?.revision ?? null) !== expectedRevision)
|
||||
return err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
||||
if (current === null) {
|
||||
return next.revision === 1 && next.previous_revision_digest === null
|
||||
? ok(null)
|
||||
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
||||
}
|
||||
if (current.name !== next.name || current.workspace_id !== next.workspace_id)
|
||||
return err(new InvestigationWorkspaceError("update", "name-conflict"));
|
||||
return next.revision === current.revision + 1 &&
|
||||
next.previous_revision_digest === current.revision_digest
|
||||
? ok(null)
|
||||
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
|
||||
}
|
||||
|
||||
const isFileNotFound = (cause: unknown): boolean =>
|
||||
errorCode(cause) === "ENOENT";
|
||||
|
||||
Reference in new issue
Block a user