mirror of
https://github.com/morluto/rea.git
synced 2026-11-04 22:06: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";
|
||||
|
||||
@@ -8,7 +8,6 @@ import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js";
|
||||
import { registerUtilityCommands } from "./cli/utilityCommands.js";
|
||||
import { registerArtifactCommands } from "./cli/artifactCommands.js";
|
||||
import { registerManagedCommands } from "./cli/managedCommands.js";
|
||||
import { registerInvestigationCommands } from "./cliInvestigationCommands.js";
|
||||
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
||||
import { registerProcessCommands } from "./cliProcessCommands.js";
|
||||
import { registerPolicyCommands } from "./cliPolicyCommands.js";
|
||||
@@ -56,7 +55,6 @@ export const createCli = (): CliInstance => {
|
||||
registerUtilityCommands(cli, logger);
|
||||
registerArtifactCommands(cli, logger);
|
||||
registerManagedCommands(cli, logger);
|
||||
registerInvestigationCommands(cli, logger);
|
||||
registerEvidenceCommands(cli, logger);
|
||||
registerProcessCommands(cli, logger);
|
||||
registerPolicyCommands(cli, logger);
|
||||
|
||||
@@ -252,8 +252,8 @@ const registerCoverageCommands = (
|
||||
authority,
|
||||
{
|
||||
path: parsed.data.workspace_path,
|
||||
readCapability: "investigation_workspace_read",
|
||||
writeCapability: "investigation_workspace_write",
|
||||
readCapability: "reconstruction_coverage_read",
|
||||
writeCapability: "reconstruction_coverage_write",
|
||||
operation: CLI_COMMANDS.commitReconstructionCoverage,
|
||||
},
|
||||
);
|
||||
@@ -282,7 +282,7 @@ const registerCoverageCommands = (
|
||||
),
|
||||
};
|
||||
const authorized = await authorizeRootPermission(authority, {
|
||||
capability: "investigation_workspace_read",
|
||||
capability: "reconstruction_coverage_read",
|
||||
roots: [parsed.data.workspace_path],
|
||||
access: "read",
|
||||
operation: CLI_COMMANDS.queryReconstructionCoverage,
|
||||
|
||||
@@ -35,7 +35,6 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
evidenceImport: "evidence-import",
|
||||
evidenceExport: "evidence-export",
|
||||
compare: "compare",
|
||||
investigateVersions: "investigate-versions",
|
||||
captureProcess: "capture-process",
|
||||
compareProcessCaptures: "compare-process-captures",
|
||||
policy: "policy",
|
||||
|
||||
@@ -78,22 +78,7 @@ export const registerEvidenceCommands = (
|
||||
left: z.string().describe("Left Evidence bundle JSON path"),
|
||||
right: z.string().describe("Right Evidence bundle JSON path"),
|
||||
}),
|
||||
options: z.object({
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe("Zero-based changed-record offset"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.default(100)
|
||||
.describe("Maximum changed records to return"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "compare", async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
@@ -105,8 +90,6 @@ export const registerEvidenceCommands = (
|
||||
const compared = await compareEvidenceBundlesCommand({
|
||||
leftPath: args.left,
|
||||
rightPath: args.right,
|
||||
offset: options.offset,
|
||||
limit: options.limit,
|
||||
policy: config.value.evidenceFilePolicy,
|
||||
});
|
||||
return compared.ok ? compared.value : cliError(compared.error);
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { Cli, z } from "incur";
|
||||
|
||||
import { runCrossVersionInvestigation } from "./application/CrossVersionInvestigation.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
import { projectAnalysisError, type AnalysisError } from "./domain/errors.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { authorizeFileReadWithDeferredWrite } from "./application/DeferredFileAuthorization.js";
|
||||
import { CLI_COMMANDS } from "./cliCommandNames.js";
|
||||
|
||||
const investigationOptionsSchema = z.object({
|
||||
yes: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Approve persistent workspace writes"),
|
||||
workspaceName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.default("default")
|
||||
.describe("Logical name recorded in the investigation workspace"),
|
||||
expectedRevision: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Required current workspace revision for an atomic update"),
|
||||
replayRunId: z
|
||||
.string()
|
||||
.regex(/^run_[a-f0-9]{64}$/u)
|
||||
.optional()
|
||||
.describe("Replay one verified complete workspace run without rescanning"),
|
||||
maxEntries: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(50_000)
|
||||
.default(10_000)
|
||||
.describe("Maximum artifact entries to inspect per version"),
|
||||
maxTotalBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(1_073_741_824)
|
||||
.describe("Maximum total uncompressed artifact content to inspect"),
|
||||
maxEntryBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(268_435_456)
|
||||
.describe("Maximum uncompressed content to inspect for one entry"),
|
||||
pageSize: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.default(500)
|
||||
.describe("Maximum graph items loaded per investigation page"),
|
||||
changeLimit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.default(500)
|
||||
.describe("Maximum version changes to record"),
|
||||
integrityPolicy: z
|
||||
.enum(["fail", "record-and-continue"])
|
||||
.default("fail")
|
||||
.describe("Behavior when declared artifact integrity does not match"),
|
||||
integrityContinueApproved: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Approve continuing after recorded integrity mismatches"),
|
||||
maxIntegrityMismatches: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(10)
|
||||
.describe("Maximum integrity mismatches to record before stopping"),
|
||||
});
|
||||
|
||||
/** Register the persistent cross-version CLI workflow. */
|
||||
export const registerInvestigationCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.investigateVersions, {
|
||||
description:
|
||||
"Run or resume a persistent cross-version artifact investigation",
|
||||
args: z.object({
|
||||
leftPath: z.string().describe("Earlier artifact or directory path"),
|
||||
rightPath: z.string().describe("Later artifact or directory path"),
|
||||
workspacePath: z.string().describe("Persistent workspace JSON path"),
|
||||
}),
|
||||
options: investigationOptionsSchema,
|
||||
alias: {
|
||||
workspaceName: "workspace-name",
|
||||
expectedRevision: "expected-revision",
|
||||
replayRunId: "replay-run-id",
|
||||
maxEntries: "max-entries",
|
||||
maxTotalBytes: "max-total-bytes",
|
||||
maxEntryBytes: "max-entry-bytes",
|
||||
pageSize: "page-size",
|
||||
changeLimit: "change-limit",
|
||||
integrityPolicy: "integrity-policy",
|
||||
integrityContinueApproved: "integrity-continue-approved",
|
||||
maxIntegrityMismatches: "max-integrity-mismatches",
|
||||
yes: "y",
|
||||
},
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "investigate-versions", () =>
|
||||
runInvestigation(args, options),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const runInvestigation = async (
|
||||
args: {
|
||||
readonly leftPath: string;
|
||||
readonly rightPath: string;
|
||||
readonly workspacePath: string;
|
||||
},
|
||||
options: z.infer<typeof investigationOptionsSchema>,
|
||||
) => {
|
||||
if (!options.yes)
|
||||
return {
|
||||
error: "ApprovalRequired",
|
||||
message: "Persistent workspace writes require explicit --yes approval.",
|
||||
};
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok) return cliError(authority.error);
|
||||
const workspaceAuthorization = await authorizeFileReadWithDeferredWrite(
|
||||
authority.value,
|
||||
{
|
||||
path: args.workspacePath,
|
||||
readCapability: "investigation_workspace_read",
|
||||
writeCapability: "investigation_workspace_write",
|
||||
operation: "investigate_versions",
|
||||
},
|
||||
);
|
||||
if (!workspaceAuthorization.ok) return cliError(workspaceAuthorization.error);
|
||||
const investigated = await runCrossVersionInvestigation(
|
||||
{
|
||||
approved: true,
|
||||
workspace_path: args.workspacePath,
|
||||
workspace_name: options.workspaceName,
|
||||
...(options.expectedRevision === undefined
|
||||
? {}
|
||||
: { expected_workspace_revision: options.expectedRevision }),
|
||||
...(options.replayRunId === undefined
|
||||
? {}
|
||||
: { replay_run_id: options.replayRunId }),
|
||||
left_path: args.leftPath,
|
||||
right_path: args.rightPath,
|
||||
integrity_policy: options.integrityPolicy,
|
||||
integrity_continue_approved: options.integrityContinueApproved,
|
||||
max_integrity_mismatches: options.maxIntegrityMismatches,
|
||||
options: {
|
||||
max_entries: options.maxEntries,
|
||||
max_total_bytes: options.maxTotalBytes,
|
||||
max_entry_bytes: options.maxEntryBytes,
|
||||
max_compression_ratio: 1_000,
|
||||
max_depth: 20,
|
||||
max_path_bytes: 4_096,
|
||||
page_size: options.pageSize,
|
||||
change_limit: options.changeLimit,
|
||||
},
|
||||
},
|
||||
config.value.evidenceFilePolicy,
|
||||
{
|
||||
integrityContinueEnabled: config.value.artifactIntegrityContinueEnabled,
|
||||
authorizeWorkspaceWrite: workspaceAuthorization.value.authorizeWrite,
|
||||
},
|
||||
);
|
||||
return investigated.ok
|
||||
? investigated.value.evidence
|
||||
: cliError(investigated.error);
|
||||
};
|
||||
|
||||
const cliError = (error: AnalysisError) => ({
|
||||
error: "Analysis failed",
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
@@ -113,7 +113,6 @@ export const INVESTIGATION_EXAMPLES = {
|
||||
},
|
||||
verify_reconstruction: {
|
||||
specification: {
|
||||
schema_version: 1,
|
||||
name: "Terminal compatibility",
|
||||
claims: [
|
||||
{
|
||||
|
||||
@@ -407,22 +407,22 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
),
|
||||
session(
|
||||
"compare_artifacts",
|
||||
"Compare two bounded sets of inventory_artifact Evidence pages by logical occurrence path, content identity, metadata, and graph relations. Pages must share and satisfy their graph commitment; every delta cites both sets, and gaps yield truncated or unknown, never equivalence.",
|
||||
"Compare complete inventory_artifact Evidence sets by logical occurrence path, content identity, metadata, and graph relations. Every delta cites both sets, and gaps yield truncated or unknown, never equivalence. Returns every change inline.",
|
||||
artifactComparisonInputSchema,
|
||||
),
|
||||
session(
|
||||
"compare_functions",
|
||||
"Compare two explicit bounded sets of analyze_function Evidence pages across identity, exact provider text, calls, references, strings, and address-normalized CFG topology. Missing or provider-incompatible facets remain truncated or unknown; every conclusion cites both Evidence sets.",
|
||||
"Compare two explicit sets of analyze_function Evidence across identity, exact provider text, calls, references, strings, and address-normalized CFG topology. Missing or provider-incompatible facets remain truncated or unknown; every conclusion cites both Evidence sets.",
|
||||
functionComparisonInputSchema,
|
||||
),
|
||||
session(
|
||||
"compare_bundles",
|
||||
"Compare two canonical Evidence v2 bundles by exact record membership, explicit one-to-one observation pairs, and complete residual-unknown revision histories. Missing bundle members describe omission only, never behavioral equivalence; output is digest-anchored and deterministically paginated.",
|
||||
"Compare two canonical Evidence bundles by exact record membership, explicit one-to-one observation pairs, and complete residual-unknown revision histories. Missing bundle members describe omission only, never behavioral equivalence; output is digest-anchored and returns every change inline.",
|
||||
bundleComparisonInputSchema,
|
||||
),
|
||||
session(
|
||||
"find_changed_behavior",
|
||||
"Aggregate validated comparison Evidence, or automatically run and resume a persistent cross-version artifact investigation beneath an approved evidence root. Runtime observations remain distinct from static behavior candidates; missing or incomplete comparisons produce unresolved findings, never causal claims.",
|
||||
"Aggregate validated process and artifact comparison Evidence. Runtime observations remain distinct from static behavior candidates; missing or incomplete comparisons produce unresolved findings, never causal claims. Returns every finding inline.",
|
||||
changedBehaviorInputSchema,
|
||||
),
|
||||
session(
|
||||
|
||||
@@ -40,7 +40,7 @@ const ANALYSIS_ERROR_TAGS = [
|
||||
"EvidenceIntegrityError",
|
||||
"EvidenceLimitError",
|
||||
"EvidenceFileError",
|
||||
"InvestigationWorkspaceError",
|
||||
"WorkspaceStorageError",
|
||||
"UnknownRegistryError",
|
||||
"HopperTimeoutError",
|
||||
"HopperCancelledError",
|
||||
@@ -351,9 +351,9 @@ export class EvidenceFileError extends AnalysisError {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persistent investigation workspace access or CAS validation failed. */
|
||||
export class InvestigationWorkspaceError extends AnalysisError {
|
||||
readonly _tag = "InvestigationWorkspaceError";
|
||||
/** A persisted reconstruction coverage workspace could not be read or updated. */
|
||||
export class WorkspaceStorageError extends AnalysisError {
|
||||
readonly _tag = "WorkspaceStorageError";
|
||||
|
||||
constructor(
|
||||
readonly operation: "read" | "update",
|
||||
@@ -370,7 +370,7 @@ export class InvestigationWorkspaceError extends AnalysisError {
|
||||
| "io",
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Investigation workspace ${operation} failed: ${reason}`, options);
|
||||
super(`Workspace storage ${operation} failed: ${reason}`, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
evidenceBundleSchema,
|
||||
parseEvidenceBundle,
|
||||
type EvidenceBundle,
|
||||
} from "./evidenceBundle.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
||||
const artifactFormatSchema =
|
||||
evidenceBundleSchema.shape.artifacts.element.shape.format;
|
||||
|
||||
/** Bounded controls for one automatic, provider-neutral artifact comparison. */
|
||||
const investigationRunOptionsSchema = z.object({
|
||||
max_entries: z.number().int().min(1).max(50_000).default(10_000),
|
||||
max_total_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(1_073_741_824),
|
||||
max_entry_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(268_435_456),
|
||||
max_compression_ratio: z.number().min(1).max(100_000).default(1_000),
|
||||
max_depth: z.number().int().min(0).max(100).default(20),
|
||||
max_path_bytes: z.number().int().min(1).max(65_535).default(4_096),
|
||||
page_size: z.number().int().min(1).max(500).default(500),
|
||||
change_limit: z.number().int().min(1).max(500).default(500),
|
||||
});
|
||||
|
||||
const DEFAULT_INVESTIGATION_RUN_OPTIONS = {
|
||||
max_entries: 10_000,
|
||||
max_total_bytes: 1_073_741_824,
|
||||
max_entry_bytes: 268_435_456,
|
||||
max_compression_ratio: 1_000,
|
||||
max_depth: 20,
|
||||
max_path_bytes: 4_096,
|
||||
page_size: 500,
|
||||
change_limit: 500,
|
||||
} as const;
|
||||
|
||||
export type InvestigationRunOptions = z.infer<
|
||||
typeof investigationRunOptionsSchema
|
||||
>;
|
||||
|
||||
/** Explicit write approval and two local versions for an automatic run. */
|
||||
const crossVersionInvestigationFacts = {
|
||||
approved: z.literal(true),
|
||||
workspace_path: z.string().min(1).max(4_096),
|
||||
workspace_name: z.string().trim().min(1).max(200).default("default"),
|
||||
expected_workspace_revision: z.number().int().min(1).optional(),
|
||||
replay_run_id: z
|
||||
.string()
|
||||
.regex(/^run_[a-f0-9]{64}$/u)
|
||||
.optional(),
|
||||
left_path: z.string().min(1).max(4_096),
|
||||
right_path: z.string().min(1).max(4_096),
|
||||
options: investigationRunOptionsSchema.default(
|
||||
DEFAULT_INVESTIGATION_RUN_OPTIONS,
|
||||
),
|
||||
} as const;
|
||||
export const crossVersionInvestigationInputSchema = z.union([
|
||||
z.object({
|
||||
...crossVersionInvestigationFacts,
|
||||
integrity_policy: z.literal("fail").default("fail"),
|
||||
integrity_continue_approved: z.literal(false).default(false),
|
||||
max_integrity_mismatches: z.number().int().min(1).max(100).default(10),
|
||||
}),
|
||||
z.object({
|
||||
...crossVersionInvestigationFacts,
|
||||
integrity_policy: z.literal("record-and-continue"),
|
||||
integrity_continue_approved: z.literal(true),
|
||||
max_integrity_mismatches: z.number().int().min(1).max(100).default(10),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type CrossVersionInvestigationInput = z.infer<
|
||||
typeof crossVersionInvestigationInputSchema
|
||||
>;
|
||||
|
||||
const targetSchema = z.object({
|
||||
root_sha256: digestSchema,
|
||||
graph_sha256: digestSchema,
|
||||
manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u),
|
||||
format: artifactFormatSchema,
|
||||
});
|
||||
|
||||
export type InvestigationRunTarget = z.infer<typeof targetSchema>;
|
||||
|
||||
const investigationRunIdentityShape = {
|
||||
schema_version: z.literal(1),
|
||||
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
|
||||
request_sha256: digestSchema,
|
||||
left: targetSchema,
|
||||
right: targetSchema,
|
||||
options: investigationRunOptionsSchema,
|
||||
left_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
right_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
limitations: z.array(z.string().max(4_096)).max(100),
|
||||
};
|
||||
const investigationRunContextShape = {
|
||||
...investigationRunIdentityShape,
|
||||
integrity_policy: z.enum(["fail", "record-and-continue"]),
|
||||
integrity_continue_approved: z.boolean(),
|
||||
max_integrity_mismatches: z.number().int().min(1).max(100),
|
||||
};
|
||||
const inventoryRunStateShape = {
|
||||
status: z.literal("running"),
|
||||
completed_stages: z.tuple([
|
||||
z.literal("inventory_left"),
|
||||
z.literal("inventory_right"),
|
||||
]),
|
||||
comparison_evidence_id: z.null(),
|
||||
result_evidence_id: z.null(),
|
||||
};
|
||||
const comparisonRunStateShape = {
|
||||
status: z.literal("running"),
|
||||
completed_stages: z.tuple([
|
||||
z.literal("inventory_left"),
|
||||
z.literal("inventory_right"),
|
||||
z.literal("compare_artifacts"),
|
||||
]),
|
||||
comparison_evidence_id: evidenceIdSchema,
|
||||
result_evidence_id: z.null(),
|
||||
};
|
||||
const completedRunStateShape = {
|
||||
status: z.literal("complete"),
|
||||
completed_stages: z.tuple([
|
||||
z.literal("inventory_left"),
|
||||
z.literal("inventory_right"),
|
||||
z.literal("compare_artifacts"),
|
||||
z.literal("find_changed_behavior"),
|
||||
]),
|
||||
comparison_evidence_id: evidenceIdSchema,
|
||||
result_evidence_id: evidenceIdSchema,
|
||||
};
|
||||
const investigationRunStateSchemas = [
|
||||
z.object({ ...investigationRunContextShape, ...inventoryRunStateShape }),
|
||||
z.object({ ...investigationRunContextShape, ...comparisonRunStateShape }),
|
||||
z.object({ ...investigationRunContextShape, ...completedRunStateShape }),
|
||||
] as const;
|
||||
const legacyIdentityRunStateSchemas = [
|
||||
z.object({
|
||||
...investigationRunContextShape,
|
||||
...inventoryRunStateShape,
|
||||
legacy_request_identity: z.literal(true),
|
||||
}),
|
||||
z.object({
|
||||
...investigationRunContextShape,
|
||||
...comparisonRunStateShape,
|
||||
legacy_request_identity: z.literal(true),
|
||||
}),
|
||||
z.object({
|
||||
...investigationRunContextShape,
|
||||
...completedRunStateShape,
|
||||
legacy_request_identity: z.literal(true),
|
||||
}),
|
||||
] as const;
|
||||
|
||||
export const investigationRunSchema = z
|
||||
.union([
|
||||
z.union(legacyIdentityRunStateSchemas),
|
||||
z.union(investigationRunStateSchemas),
|
||||
])
|
||||
.superRefine((run, context) => {
|
||||
if (!("legacy_request_identity" in run)) return;
|
||||
const legacyIdentity = createLegacyInvestigationRunIdentity(run);
|
||||
if (
|
||||
run.integrity_policy !== "fail" ||
|
||||
run.integrity_continue_approved ||
|
||||
run.max_integrity_mismatches !== 10 ||
|
||||
run.run_id !== legacyIdentity.runId ||
|
||||
run.request_sha256 !== legacyIdentity.requestSha256
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["legacy_request_identity"],
|
||||
message:
|
||||
"Legacy request identity is valid only for migrated strict runs",
|
||||
});
|
||||
});
|
||||
|
||||
export type InvestigationRun = z.infer<typeof investigationRunSchema>;
|
||||
|
||||
export const investigationRunSummarySchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
workspace_id: z.string().regex(/^ws_[a-f0-9]{64}$/u),
|
||||
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
|
||||
left_manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u),
|
||||
right_manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u),
|
||||
inventory_evidence_count: z.number().int().min(2).max(200),
|
||||
comparison_evidence_id: evidenceIdSchema,
|
||||
limitations: z.array(z.string().max(4_096)).max(100),
|
||||
});
|
||||
|
||||
/** Durable schema for one revisioned cross-version investigation workspace. */
|
||||
export const investigationWorkspaceSchema = z.object({
|
||||
workspace_version: z.literal(1),
|
||||
workspace_id: z.string().regex(/^ws_[a-f0-9]{64}$/u),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
revision: z.number().int().min(1),
|
||||
previous_revision_digest: z
|
||||
.string()
|
||||
.regex(/^wrev_[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
revision_digest: z.string().regex(/^wrev_[a-f0-9]{64}$/u),
|
||||
bundle: evidenceBundleSchema,
|
||||
runs: z.array(investigationRunSchema).max(1_000),
|
||||
});
|
||||
|
||||
const legacyInvestigationRunSchema = z.union([
|
||||
z.strictObject({
|
||||
...investigationRunIdentityShape,
|
||||
...inventoryRunStateShape,
|
||||
}),
|
||||
z.strictObject({
|
||||
...investigationRunIdentityShape,
|
||||
...comparisonRunStateShape,
|
||||
}),
|
||||
z.strictObject({
|
||||
...investigationRunIdentityShape,
|
||||
...completedRunStateShape,
|
||||
}),
|
||||
]);
|
||||
|
||||
const legacyInvestigationWorkspaceSchema = investigationWorkspaceSchema
|
||||
.extend({
|
||||
runs: z.array(legacyInvestigationRunSchema).max(1_000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type InvestigationWorkspace = z.infer<
|
||||
typeof investigationWorkspaceSchema
|
||||
>;
|
||||
|
||||
/** Derive a stable run identity from content commitments and bounded controls. */
|
||||
export const createInvestigationRunIdentity = (input: {
|
||||
readonly left: InvestigationRunTarget;
|
||||
readonly right: InvestigationRunTarget;
|
||||
readonly options: InvestigationRunOptions;
|
||||
readonly integrity_policy: CrossVersionInvestigationInput["integrity_policy"];
|
||||
readonly integrity_continue_approved: boolean;
|
||||
readonly max_integrity_mismatches: number;
|
||||
}): { readonly runId: string; readonly requestSha256: string } => {
|
||||
const requestSha256 = digestCanonical({
|
||||
schema: "rea.cross-version-investigation-request/v1",
|
||||
left: input.left,
|
||||
right: input.right,
|
||||
options: input.options,
|
||||
integrity: {
|
||||
policy: input.integrity_policy,
|
||||
approved: input.integrity_continue_approved,
|
||||
max_mismatches: input.max_integrity_mismatches,
|
||||
},
|
||||
});
|
||||
return { runId: `run_${requestSha256}`, requestSha256 };
|
||||
};
|
||||
|
||||
/** Build the first canonical workspace revision. */
|
||||
export const createInvestigationWorkspace = (
|
||||
name: string,
|
||||
bundle: EvidenceBundle,
|
||||
runs: readonly InvestigationRun[],
|
||||
): InvestigationWorkspace =>
|
||||
buildWorkspace({
|
||||
workspaceId: `ws_${digestCanonical({ schema: "rea.workspace/v1", name })}`,
|
||||
name,
|
||||
revision: 1,
|
||||
previousRevisionDigest: null,
|
||||
bundle,
|
||||
runs,
|
||||
});
|
||||
|
||||
/** Create the next immutable workspace revision. */
|
||||
export const reviseInvestigationWorkspace = (
|
||||
current: InvestigationWorkspace,
|
||||
bundle: EvidenceBundle,
|
||||
runs: readonly InvestigationRun[],
|
||||
): InvestigationWorkspace =>
|
||||
buildWorkspace({
|
||||
workspaceId: current.workspace_id,
|
||||
name: current.name,
|
||||
revision: current.revision + 1,
|
||||
previousRevisionDigest: current.revision_digest,
|
||||
bundle,
|
||||
runs,
|
||||
});
|
||||
|
||||
/** Parse a workspace and verify identities, revision digest, and references. */
|
||||
export const parseInvestigationWorkspace = (
|
||||
input: unknown,
|
||||
): InvestigationWorkspace => {
|
||||
if (isLegacyWorkspace(input)) return migrateLegacyWorkspace(input);
|
||||
const parsed = investigationWorkspaceSchema.parse(input);
|
||||
const bundle = parseEvidenceBundle(parsed.bundle);
|
||||
const canonical = buildWorkspace({
|
||||
workspaceId: parsed.workspace_id,
|
||||
name: parsed.name,
|
||||
revision: parsed.revision,
|
||||
previousRevisionDigest: parsed.previous_revision_digest,
|
||||
bundle,
|
||||
runs: parsed.runs,
|
||||
});
|
||||
if (parsed.workspace_id !== workspaceIdFor(parsed.name))
|
||||
throw new TypeError("Investigation workspace ID does not match its name");
|
||||
if (parsed.revision_digest !== canonical.revision_digest)
|
||||
throw new TypeError("Investigation workspace revision digest is invalid");
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(canonical))
|
||||
throw new TypeError("Investigation workspace is not canonical");
|
||||
validateRunReferences(canonical);
|
||||
return canonical;
|
||||
};
|
||||
|
||||
/** Encode a validated workspace as byte-stable canonical JSON. */
|
||||
export const serializeInvestigationWorkspace = (
|
||||
workspace: InvestigationWorkspace,
|
||||
): string => canonicalJson(parseInvestigationWorkspace(workspace));
|
||||
|
||||
const buildWorkspace = (input: {
|
||||
readonly workspaceId: string;
|
||||
readonly name: string;
|
||||
readonly revision: number;
|
||||
readonly previousRevisionDigest: string | null;
|
||||
readonly bundle: EvidenceBundle;
|
||||
readonly runs: readonly InvestigationRun[];
|
||||
}): InvestigationWorkspace => {
|
||||
const runs = [...input.runs]
|
||||
.map((run) => investigationRunSchema.parse(run))
|
||||
.sort((left, right) => left.run_id.localeCompare(right.run_id));
|
||||
assertUnique(
|
||||
runs.map(({ run_id: id }) => id),
|
||||
"investigation run ID",
|
||||
);
|
||||
const semantic = {
|
||||
workspace_version: 1,
|
||||
workspace_id: input.workspaceId,
|
||||
name: input.name,
|
||||
revision: input.revision,
|
||||
previous_revision_digest: input.previousRevisionDigest,
|
||||
bundle: parseEvidenceBundle(input.bundle),
|
||||
runs,
|
||||
} satisfies JsonValue;
|
||||
return investigationWorkspaceSchema.parse({
|
||||
...semantic,
|
||||
revision_digest: `wrev_${digestCanonical(semantic)}`,
|
||||
});
|
||||
};
|
||||
|
||||
const validateRunReferences = (workspace: InvestigationWorkspace): void => {
|
||||
const evidenceIds = new Set(
|
||||
workspace.bundle.records.map(({ evidence_id: id }) => id),
|
||||
);
|
||||
for (const run of workspace.runs) {
|
||||
const expectedIdentity = createInvestigationRunIdentity(run);
|
||||
const legacyIdentity = createLegacyInvestigationRunIdentity(run);
|
||||
if (
|
||||
(run.run_id !== expectedIdentity.runId ||
|
||||
run.request_sha256 !== expectedIdentity.requestSha256) &&
|
||||
(!("legacy_request_identity" in run) ||
|
||||
run.integrity_policy !== "fail" ||
|
||||
run.integrity_continue_approved ||
|
||||
run.max_integrity_mismatches !== 10 ||
|
||||
run.run_id !== legacyIdentity.runId ||
|
||||
run.request_sha256 !== legacyIdentity.requestSha256)
|
||||
)
|
||||
throw new TypeError("Investigation run identity is invalid");
|
||||
const references = [
|
||||
...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]),
|
||||
];
|
||||
if (references.some((evidenceId) => !evidenceIds.has(evidenceId)))
|
||||
throw new TypeError("Investigation run references missing Evidence");
|
||||
}
|
||||
};
|
||||
|
||||
/** Derive the pre-integrity-policy run identity for strict workspace migration. */
|
||||
export const createLegacyInvestigationRunIdentity = (input: {
|
||||
readonly left: InvestigationRunTarget;
|
||||
readonly right: InvestigationRunTarget;
|
||||
readonly options: InvestigationRunOptions;
|
||||
}): { readonly runId: string; readonly requestSha256: string } => {
|
||||
const requestSha256 = digestCanonical({
|
||||
schema: "rea.cross-version-investigation-request/v1",
|
||||
left: input.left,
|
||||
right: input.right,
|
||||
options: input.options,
|
||||
});
|
||||
return { runId: `run_${requestSha256}`, requestSha256 };
|
||||
};
|
||||
|
||||
const isLegacyWorkspace = (input: unknown): boolean => {
|
||||
if (typeof input !== "object" || input === null || !("runs" in input))
|
||||
return false;
|
||||
const runs = input.runs;
|
||||
return (
|
||||
Array.isArray(runs) &&
|
||||
runs.some(
|
||||
(run) =>
|
||||
typeof run === "object" && run !== null && !("integrity_policy" in run),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const migrateLegacyWorkspace = (input: unknown): InvestigationWorkspace => {
|
||||
const parsed = legacyInvestigationWorkspaceSchema.parse(input);
|
||||
const bundle = parseEvidenceBundle(parsed.bundle);
|
||||
const runs = [...parsed.runs].sort((left, right) =>
|
||||
left.run_id.localeCompare(right.run_id),
|
||||
);
|
||||
const semantic = {
|
||||
workspace_version: 1,
|
||||
workspace_id: parsed.workspace_id,
|
||||
name: parsed.name,
|
||||
revision: parsed.revision,
|
||||
previous_revision_digest: parsed.previous_revision_digest,
|
||||
bundle,
|
||||
runs,
|
||||
} satisfies JsonValue;
|
||||
if (parsed.workspace_id !== workspaceIdFor(parsed.name))
|
||||
throw new TypeError("Investigation workspace ID does not match its name");
|
||||
if (parsed.revision_digest !== `wrev_${digestCanonical(semantic)}`)
|
||||
throw new TypeError("Investigation workspace revision digest is invalid");
|
||||
const canonical = legacyInvestigationWorkspaceSchema.parse({
|
||||
...semantic,
|
||||
revision_digest: parsed.revision_digest,
|
||||
});
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(canonical))
|
||||
throw new TypeError("Investigation workspace is not canonical");
|
||||
for (const run of runs) {
|
||||
const identity = createLegacyInvestigationRunIdentity(run);
|
||||
if (
|
||||
run.run_id !== identity.runId ||
|
||||
run.request_sha256 !== identity.requestSha256
|
||||
)
|
||||
throw new TypeError("Investigation run identity is invalid");
|
||||
}
|
||||
const migrated = buildWorkspace({
|
||||
workspaceId: parsed.workspace_id,
|
||||
name: parsed.name,
|
||||
revision: parsed.revision,
|
||||
previousRevisionDigest: parsed.previous_revision_digest,
|
||||
bundle,
|
||||
runs: runs.map((run) => ({
|
||||
...run,
|
||||
legacy_request_identity: true as const,
|
||||
integrity_policy: "fail" as const,
|
||||
integrity_continue_approved: false,
|
||||
max_integrity_mismatches: 10,
|
||||
})),
|
||||
});
|
||||
validateRunReferences(migrated);
|
||||
return migrated;
|
||||
};
|
||||
|
||||
const workspaceIdFor = (name: string): string =>
|
||||
`ws_${digestCanonical({ schema: "rea.workspace/v1", name })}`;
|
||||
|
||||
const digestCanonical = (value: JsonValue): string =>
|
||||
createHash("sha256").update(canonicalJson(value)).digest("hex");
|
||||
|
||||
const canonicalJson = (value: JsonValue): string => {
|
||||
const encoded = canonicalize(value);
|
||||
if (encoded === undefined)
|
||||
throw new TypeError("Investigation workspace could not canonicalize data");
|
||||
return encoded;
|
||||
};
|
||||
|
||||
const assertUnique = (values: readonly string[], description: string): void => {
|
||||
if (new Set(values).size !== values.length)
|
||||
throw new TypeError(`Duplicate ${description}`);
|
||||
};
|
||||
@@ -12,8 +12,8 @@ export const PERMISSION_CAPABILITIES = [
|
||||
"v8_inspector_observe",
|
||||
"evidence_read",
|
||||
"evidence_write",
|
||||
"investigation_workspace_read",
|
||||
"investigation_workspace_write",
|
||||
"reconstruction_coverage_read",
|
||||
"reconstruction_coverage_write",
|
||||
"snapshot_read",
|
||||
"snapshot_write",
|
||||
"artifact_extract",
|
||||
|
||||
@@ -30,7 +30,7 @@ interface GeneratedPayload {
|
||||
}
|
||||
|
||||
const GENERATED_PAYLOAD_JSON =
|
||||
'{"auxiliaryProviders":[{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"limits":{"maxPayloadBytes":4194304,"maxResults":500,"timeoutMs":120000},"operation":"inventory_artifact","pagination":"offset","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"limits":{"maxPayloadBytes":4194304,"maxResults":500,"timeoutMs":120000},"operation":"inspect_artifact","pagination":"none","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"limits":{"maxPayloadBytes":4194304,"maxResults":500,"timeoutMs":120000},"operation":"extract_artifact","pagination":"offset","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null}],"identity":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":false,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"inspect_macho","pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":true,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"inspect_signature","pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":true,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"inspect_plist","pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":true,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":null,"timeoutMs":30000},"operation":"list_architectures","pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"exhaustive":true,"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"limits":{"maxPayloadBytes":4194304,"maxResults":500,"timeoutMs":30000},"operation":"demangle_swift","pagination":"none","provider":{"id":"native-macos","name":"macOS native inspection utilities","vLine truncated
|
||||
'{"auxiliaryProviders":[{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inventory_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"extract_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null}],"identity":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"inspect_macho","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"inspect_signature","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"inspect_plist","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"list_architectures","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"demangle_swift","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null}],"identity":{"id":"native-macos","name":"macOS native inspection utilities","version":null}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["This capability inventories PE/CLI identity only; inspect_managed_members admits bounded metadata members, signatures, and CIL anchors.","It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code."],"operation":"inspect_managed_artifact","provider":{"id":"rea-dotnet-static","name":"REA managed static analysis provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesysLine truncated
|
||||
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
||||
if (!isGeneratedPayload(generatedPayload))
|
||||
throw new TypeError("Generated MCP catalog payload is invalid");
|
||||
|
||||
@@ -1,38 +1,22 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
import { runCrossVersionInvestigationValidated } from "../application/CrossVersionInvestigation.js";
|
||||
import {
|
||||
authorizeFileReadWithDeferredWrite,
|
||||
type DeferredFileWriteAuthorization,
|
||||
} from "../application/DeferredFileAuthorization.js";
|
||||
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { buildCallPath } from "../domain/callPath.js";
|
||||
import {
|
||||
changedBehaviorInputSchema,
|
||||
changedBehaviorResultSchema,
|
||||
findChangedBehavior,
|
||||
} from "../domain/changedBehavior.js";
|
||||
import { findChangedBehavior } from "../domain/changedBehavior.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { verifyReconstruction } from "../domain/reconstructionVerification.js";
|
||||
import { correlateStaticAndRuntime } from "../domain/staticRuntimeCorrelation.js";
|
||||
import { mcpProgressReporter } from "./mcpProgress.js";
|
||||
import {
|
||||
comparisonClosure,
|
||||
evidenceClosure,
|
||||
functionEvidenceIds,
|
||||
investigationContext,
|
||||
isIncomplete,
|
||||
recordWorkflowEvidence,
|
||||
verifyCoverageReadiness,
|
||||
} from "./registerInvestigationTools/helpers.js";
|
||||
import type { InvestigationToolPolicies } from "./registerInvestigationTools/types.js";
|
||||
import {
|
||||
runDerivedOperation,
|
||||
type DerivedOperationContext,
|
||||
} from "./runDerivedOperation.js";
|
||||
import { runDerivedOperation } from "./runDerivedOperation.js";
|
||||
import {
|
||||
CALL_PATH_PROVIDER,
|
||||
CHANGED_BEHAVIOR_PROVIDER,
|
||||
@@ -52,9 +36,8 @@ export const registerInvestigationTools = (
|
||||
(typeof SESSION_TOOL_CONTRACTS)[12],
|
||||
(typeof SESSION_TOOL_CONTRACTS)[13],
|
||||
],
|
||||
policies: InvestigationToolPolicies,
|
||||
): void => {
|
||||
registerChangedBehavior(server, session, contracts[0], policies);
|
||||
registerChangedBehavior(server, session, contracts[0]);
|
||||
registerCallPath(server, session, contracts[1]);
|
||||
registerStaticRuntime(server, session, contracts[2]);
|
||||
registerReconstruction(server, session, contracts[3]);
|
||||
@@ -64,23 +47,11 @@ const registerChangedBehavior = (
|
||||
server: McpServer,
|
||||
session: BinarySessionPort,
|
||||
contract: (typeof SESSION_TOOL_CONTRACTS)[10],
|
||||
policies: InvestigationToolPolicies,
|
||||
): void => {
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
contractOptions(contract),
|
||||
async (input, context) => {
|
||||
const investigationRun = input.investigation_run;
|
||||
if (investigationRun !== undefined)
|
||||
return runAutomaticInvestigation({
|
||||
server,
|
||||
session,
|
||||
contract,
|
||||
policies,
|
||||
investigationRun,
|
||||
unknownRegistryApproved: input.unknown_registry_approved,
|
||||
context,
|
||||
});
|
||||
const closure = evidenceClosure(
|
||||
session,
|
||||
comparisonClosure(input.comparisons),
|
||||
@@ -88,7 +59,7 @@ const registerChangedBehavior = (
|
||||
if (!closure.ok) return toCallToolResult(closure, contract);
|
||||
const links = closure.value;
|
||||
const computed = await runDerivedOperation(context, contract.name, () =>
|
||||
findChangedBehavior(input.comparisons, input.offset, input.limit),
|
||||
findChangedBehavior(input.comparisons),
|
||||
);
|
||||
if (!computed.ok) return toCallToolResult(computed, contract);
|
||||
const result = computed.value;
|
||||
@@ -99,8 +70,6 @@ const registerChangedBehavior = (
|
||||
comparison_evidence_ids: input.comparisons.map(
|
||||
({ evidence_id: id }) => id,
|
||||
),
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "derived",
|
||||
@@ -133,81 +102,6 @@ const registerChangedBehavior = (
|
||||
);
|
||||
};
|
||||
|
||||
const runAutomaticInvestigation = async (input: {
|
||||
readonly server: McpServer;
|
||||
readonly session: BinarySessionPort;
|
||||
readonly contract: (typeof SESSION_TOOL_CONTRACTS)[10];
|
||||
readonly policies: InvestigationToolPolicies;
|
||||
readonly investigationRun: NonNullable<
|
||||
z.output<typeof changedBehaviorInputSchema>["investigation_run"]
|
||||
>;
|
||||
readonly unknownRegistryApproved: true | undefined;
|
||||
readonly context: DerivedOperationContext;
|
||||
}) => {
|
||||
const { policies, investigationRun, contract, session } = input;
|
||||
const progress = mcpProgressReporter(input.context);
|
||||
let workspaceAuthorization: DeferredFileWriteAuthorization | undefined;
|
||||
if (policies.permissionAuthority !== undefined) {
|
||||
const authority = policies.permissionAuthority;
|
||||
const authorizedWorkspace = await authorizeFileReadWithDeferredWrite(
|
||||
authority,
|
||||
{
|
||||
path: investigationRun.workspace_path,
|
||||
readCapability: "investigation_workspace_read",
|
||||
writeCapability: "investigation_workspace_write",
|
||||
operation: contract.name,
|
||||
},
|
||||
);
|
||||
if (!authorizedWorkspace.ok)
|
||||
return toCallToolResult(authorizedWorkspace, contract);
|
||||
workspaceAuthorization = authorizedWorkspace.value;
|
||||
}
|
||||
const investigated = await runCrossVersionInvestigationValidated(
|
||||
investigationRun,
|
||||
policies.evidenceFiles,
|
||||
{
|
||||
...investigationContext({
|
||||
session,
|
||||
signal: input.context.mcpReq.signal,
|
||||
integrityContinueEnabled:
|
||||
policies.integrityContinueEnabled?.() ?? false,
|
||||
progress,
|
||||
}),
|
||||
...(workspaceAuthorization === undefined
|
||||
? {}
|
||||
: { authorizeWorkspaceWrite: workspaceAuthorization.authorizeWrite }),
|
||||
},
|
||||
);
|
||||
if (!investigated.ok) return toCallToolResult(investigated, contract);
|
||||
session.retainInvestigationWorkspace(investigated.value.workspace);
|
||||
const result = changedBehaviorResultSchema.parse(
|
||||
investigated.value.evidence.normalized_result,
|
||||
);
|
||||
const toolResult = toCallToolResult(
|
||||
recordWorkflowEvidence(
|
||||
session,
|
||||
investigated.value.evidence,
|
||||
input.unknownRegistryApproved,
|
||||
isIncomplete(result.behavior_status),
|
||||
{
|
||||
question: `Automatic changed behavior run ${result.investigation_run?.run_id ?? "unknown"} remains ${result.behavior_status}`,
|
||||
domain: "changed-behavior",
|
||||
requiredAuthority: "controlled-replay",
|
||||
requiredConfidence: "observed",
|
||||
probes: [
|
||||
{
|
||||
operation: "capture_process_scenario",
|
||||
rationale:
|
||||
"Capture both versions under the same bounded scenario and environment.",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
contract,
|
||||
);
|
||||
return toolResult;
|
||||
};
|
||||
|
||||
const registerCallPath = (
|
||||
server: McpServer,
|
||||
session: BinarySessionPort,
|
||||
@@ -294,8 +188,6 @@ const registerStaticRuntime = (
|
||||
operation: contract.name,
|
||||
parameters: {
|
||||
mapping_count: input.mappings.length,
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "inferred",
|
||||
@@ -343,12 +235,7 @@ const registerReconstruction = (
|
||||
if (!coverage.ok) return toCallToolResult(coverage, contract);
|
||||
const owned = session.exportEvidenceBundle();
|
||||
const computed = await runDerivedOperation(context, contract.name, () =>
|
||||
verifyReconstruction(
|
||||
input.specification,
|
||||
owned,
|
||||
input.offset,
|
||||
input.limit,
|
||||
),
|
||||
verifyReconstruction(input.specification, owned),
|
||||
);
|
||||
if (!computed.ok) return toCallToolResult(computed, contract);
|
||||
const result = computed.value;
|
||||
@@ -361,8 +248,6 @@ const registerReconstruction = (
|
||||
parameters: {
|
||||
specification_sha256: result.specification_sha256,
|
||||
claim_ids: input.specification.claims.map(({ claim_id: id }) => id),
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "derived",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { BinarySessionPort } from "../../application/BinarySession.js";
|
||||
import type { ProgressReporter } from "../../application/ProgressReporter.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
@@ -13,29 +12,6 @@ import type { z } from "zod";
|
||||
import type { reconstructionVerificationInputSchema } from "../../domain/reconstructionVerification.js";
|
||||
import { AnalysisInputError } from "../../domain/errors.js";
|
||||
|
||||
/** Build the execution context passed to cross-version investigation. */
|
||||
export const investigationContext = ({
|
||||
session,
|
||||
signal,
|
||||
integrityContinueEnabled,
|
||||
progress,
|
||||
}: {
|
||||
readonly session: BinarySessionPort;
|
||||
readonly signal: AbortSignal;
|
||||
readonly integrityContinueEnabled: boolean;
|
||||
readonly progress?: ProgressReporter;
|
||||
}): {
|
||||
readonly session: BinarySessionPort;
|
||||
readonly signal: AbortSignal;
|
||||
readonly integrityContinueEnabled: boolean;
|
||||
readonly progress?: ProgressReporter;
|
||||
} => ({
|
||||
session,
|
||||
signal,
|
||||
integrityContinueEnabled,
|
||||
...(progress === undefined ? {} : { progress }),
|
||||
});
|
||||
|
||||
export const isIncomplete = (status: string): boolean =>
|
||||
status === "unknown" || status === "truncated";
|
||||
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import type { EvidenceFilePolicy } from "../../domain/evidenceBundle.js";
|
||||
import type { PermissionAuthority } from "../../application/PermissionAuthority.js";
|
||||
|
||||
/** Policy inputs shared by all investigation workflow tools. */
|
||||
export interface InvestigationToolPolicies {
|
||||
readonly evidenceFiles: EvidenceFilePolicy;
|
||||
readonly permissionAuthority?: PermissionAuthority;
|
||||
readonly integrityContinueEnabled?: () => boolean;
|
||||
}
|
||||
|
||||
/** Fields used to build an approved residual unknown from a workflow result. */
|
||||
export interface WorkflowUnknownInput {
|
||||
readonly question: string;
|
||||
|
||||
@@ -412,17 +412,7 @@ export const registerSessionTools = (
|
||||
staticRuntimeContract,
|
||||
reconstructionContract,
|
||||
] as const;
|
||||
registerInvestigationTools(server, session, investigationContracts, {
|
||||
evidenceFiles: evidenceFilePolicy,
|
||||
...(options.artifactIntegrityContinueEnabled === undefined
|
||||
? {}
|
||||
: {
|
||||
integrityContinueEnabled: options.artifactIntegrityContinueEnabled,
|
||||
}),
|
||||
...(options.permissionAuthority === undefined
|
||||
? {}
|
||||
: { permissionAuthority: options.permissionAuthority }),
|
||||
});
|
||||
registerInvestigationTools(server, session, investigationContracts);
|
||||
registerRecordAndReplayTools(server, session, logger);
|
||||
registerContextTools(server, session);
|
||||
};
|
||||
Reference in new issue
Block a user