refactor: remove cross-version investigation workflow

This commit is contained in:
morluto committed 2026-09-29 08:06:43 +08:00
1 parent 0d96275b68
commit 81996df7a5
42 files changed
+66 -3750

No files matched your search

-9
View File
@@ -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[];
};
-9
View File
@@ -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";
+1 -30
View File
@@ -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" {
-7
View File
@@ -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: [],
})),
});
-219
View File
@@ -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, {
@@ -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";