refactor: remove cross-version investigation workflow
This commit is contained in:
parent
0d96275b68
commit
81996df7a5
13
README.md
13
README.md
|
|
@ -266,7 +266,6 @@ Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots
|
|||
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
|
||||
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
|
||||
| Validate, canonicalize, or compare Evidence bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
|
||||
| Run or resume a persistent two-version artifact analysis | `rea investigate-versions` |
|
||||
| Map a local JavaScript/Electron application without executing it | `rea analyze PATH --approved` or `rea analyze-javascript-application` |
|
||||
| Reuse immutable analysis results without relaunching a provider | Pass `--snapshot /approved/path/analysis.json` to a deep-analysis command |
|
||||
| Import source as historical reference | `rea import-reference-source` |
|
||||
|
|
@ -279,7 +278,6 @@ export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
|
|||
rea evidence-import /absolute/path/to/evidence/bundle.json
|
||||
rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json
|
||||
rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json
|
||||
rea investigate-versions /absolute/path/to/releases/v1 /absolute/path/to/releases/v2 /absolute/path/to/evidence/releases.json --yes --workspace-name releases
|
||||
rea analyze /absolute/path/to/releases/app.asar --approved --json
|
||||
rea analyze-javascript-application /absolute/path/to/releases/app.asar --approved --json
|
||||
```
|
||||
|
|
@ -289,16 +287,6 @@ static JavaScript application provider when neither `--provider` nor
|
|||
`--snapshot` is supplied. The dedicated command remains available for explicit
|
||||
format and traversal controls. Both routes read the selected local input path.
|
||||
|
||||
`investigate-versions` inventories both versions, checkpoints their observed
|
||||
Evidence, derives an artifact comparison, and records a changed-behavior report.
|
||||
workspace files remain independently restricted by `REA_EVIDENCE_ROOTS_JSON`.
|
||||
The workspace uses deterministic content identities and monotonic CAS-linked
|
||||
revisions, so the same request resumes an interrupted run or reuses a completed
|
||||
run without replacing earlier investigations. It currently compares static
|
||||
artifact structure only; it does not execute either version, and its report
|
||||
keeps every difference labeled as a behavior candidate. See
|
||||
[Persistent investigation workspaces](docs/investigation-workspaces.md).
|
||||
|
||||
Historical source import requires a separate allowlist and never treats source as current behavioral authority:
|
||||
|
||||
```bash
|
||||
|
|
@ -584,7 +572,6 @@ npx -y rea-agents@latest function /Applications/Notes.app 0x1000
|
|||
npx -y rea-agents@latest xrefs /Applications/Notes.app 0x1000
|
||||
npx -y rea-agents@latest trace /Applications/Notes.app "offline"
|
||||
npx -y rea-agents@latest compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
|
||||
npx -y rea-agents@latest investigate-versions /path/to/v1 /path/to/v2 /absolute/path/to/evidence/releases.json --yes
|
||||
npx -y rea-agents@latest capabilities
|
||||
npx -y rea-agents@latest providers
|
||||
```
|
||||
|
|
|
|||
|
|
@ -328,11 +328,11 @@ entries from the previous binding. Auxiliary operations are cacheable only if
|
|||
their provider defines an equivalent explicit profile commitment; otherwise
|
||||
they remain Evidence but not snapshot entries.
|
||||
|
||||
Snapshot v1 is not silently upgraded. Its `loader_args` cannot prove which
|
||||
engine version, defaults, or analyzer settings produced the entries. Importing
|
||||
v1 returns a typed incompatibility with a recapture instruction. Its embedded
|
||||
Evidence bundle may still be imported separately through the existing explicit
|
||||
Evidence import flow; it is never promoted into a v2 analysis cache.
|
||||
Snapshot v1 cannot be parsed as the current snapshot format. Its `loader_args`
|
||||
cannot prove which engine version, defaults, or analyzer settings produced the
|
||||
entries, so snapshots must be recaptured before they can be used as a cache.
|
||||
An embedded Evidence bundle can still be imported separately through the
|
||||
Evidence import flow; it is never promoted into an analysis cache.
|
||||
|
||||
### 8. Add profile commitments to new deep Evidence without rejecting legacy Evidence v2
|
||||
|
||||
|
|
@ -457,7 +457,7 @@ snapshot's provider.
|
|||
| `open_binary` and CLI structured `loaderArgs` output | Retained as a deprecated Hopper-only compatibility projection for 1.x; the analysis profile is authoritative |
|
||||
| `BinaryTarget.loaderArgs` | Removed; Hopper derives them inside its adapter and commits normalized semantics in the profile |
|
||||
| Legacy `.hop` input | Classified generically as an analysis database and accepted only by Hopper |
|
||||
| Snapshot v1 | Rejected for cache replay with explicit recapture guidance; embedded Evidence may be imported separately |
|
||||
| Snapshot v1 | Not accepted as the current snapshot format; recapture before cache replay |
|
||||
| Existing Evidence v2 without a profile | Accepted unchanged; profile compatibility is unknown |
|
||||
| New deep-analysis Evidence v2 | Must include `analysis_profile`; its semantic ID commits the field |
|
||||
| Existing status fields | Retained for 1.x with additive binding and candidate fields |
|
||||
|
|
@ -483,7 +483,6 @@ with a separately documented major contract change.
|
|||
| Same target is reopened without a selector | Existing binding remains unchanged |
|
||||
| Same bytes are opened with a different profile | Real provider switch and a separate snapshot partition |
|
||||
| Snapshot provider or profile differs from the binding | Typed `EvidenceIntegrityError` with `profile_mismatch`; nothing is imported |
|
||||
| Snapshot v1 is supplied | Typed incompatibility with recapture or explicit Evidence-import guidance |
|
||||
| Artifact-only target has no deep candidate | Target opens unbound; disjoint artifact capabilities remain usable |
|
||||
| Environment prefers Ghidra for an artifact-only target | Deep selection is `not_applicable`; auxiliary operations remain usable |
|
||||
| Concrete deep provider is requested for artifact-only data | Typed `target_unsupported`; the request is not silently ignored |
|
||||
|
|
@ -502,7 +501,7 @@ Positive consequences:
|
|||
Costs and tradeoffs:
|
||||
|
||||
- Multi-provider hosts must choose when more than one candidate is usable.
|
||||
- Snapshot v1 cannot safely serve as a cache after this migration.
|
||||
- Snapshots must be recaptured when their provider or profile binding changes.
|
||||
- Providers must expose deterministic availability, support, version, and
|
||||
profile resolution before they can be admitted.
|
||||
- Status and error contracts gain additive structured metadata.
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
# Persistent investigation workspaces
|
||||
|
||||
REA can run a deterministic artifact investigation across two versions and
|
||||
persist its Evidence beyond a CLI process or MCP session. The CLI and MCP use
|
||||
the same application workflow.
|
||||
|
||||
## CLI
|
||||
|
||||
First allow the directory that will own the workspace, then explicitly approve
|
||||
the write:
|
||||
|
||||
```bash
|
||||
export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
|
||||
|
||||
rea investigate-versions \
|
||||
/absolute/path/to/version-1 \
|
||||
/absolute/path/to/version-2 \
|
||||
/absolute/path/to/evidence/releases.json \
|
||||
--workspace-name releases \
|
||||
--yes
|
||||
```
|
||||
|
||||
The two versions may be regular artifacts or directories supported by the
|
||||
artifact graph provider. Workspace files remain restricted by
|
||||
`REA_EVIDENCE_ROOTS_JSON`; input paths are used directly by the local process.
|
||||
|
||||
Use `--expected-revision N` when an external coordinator needs an explicit
|
||||
compare-and-swap guard. Traversal, byte, page, and comparison controls are
|
||||
available as CLI options.
|
||||
|
||||
After a completed run returns its stable run ID, `--replay-run-id RUN_ID`
|
||||
selects that exact result without reading or rescanning either version input.
|
||||
The workspace still requires read authority. REA rejects a selector that is
|
||||
missing, incomplete, or inconsistent with the requested paths and controls.
|
||||
|
||||
## MCP
|
||||
|
||||
`find_changed_behavior` retains its existing aggregation mode. Its additive
|
||||
`investigation_run` mode starts or resumes the same persistent workflow:
|
||||
|
||||
```json
|
||||
{
|
||||
"investigation_run": {
|
||||
"approved": true,
|
||||
"workspace_path": "/absolute/path/to/evidence/releases.json",
|
||||
"workspace_name": "releases",
|
||||
"left_path": "/absolute/path/to/version-1",
|
||||
"right_path": "/absolute/path/to/version-2",
|
||||
"options": {
|
||||
"max_entries": 10000,
|
||||
"max_total_bytes": 1073741824,
|
||||
"max_entry_bytes": 268435456,
|
||||
"max_compression_ratio": 1000,
|
||||
"max_depth": 20,
|
||||
"max_path_bytes": 4096,
|
||||
"page_size": 500,
|
||||
"change_limit": 500
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exactly one of `comparisons` or `investigation_run` is accepted. The MCP tool
|
||||
inventory remains unchanged.
|
||||
|
||||
## Checkpoints and reuse
|
||||
|
||||
One run has four ordered stages:
|
||||
|
||||
1. Inventory the left version.
|
||||
2. Inventory the right version.
|
||||
3. Compare the complete artifact Evidence page sets.
|
||||
4. Derive the changed-behavior report.
|
||||
|
||||
The inventory checkpoint is written first, followed by the comparison and final
|
||||
report checkpoints. A process interrupted after a checkpoint can resume from
|
||||
the last complete stage. A completed run is reused when both graph commitments
|
||||
and every bounded option are identical.
|
||||
|
||||
By default, REA rescans the version inputs so changed content creates a new
|
||||
run. For an explicit cache-only replay, add the returned `run_id` as
|
||||
`investigation_run.replay_run_id`. REA validates the workspace revision and
|
||||
name, the complete run, its exact paths and controls, and every linked Evidence
|
||||
record before it skips input-read authorization.
|
||||
|
||||
The run ID is a SHA-256 commitment to both root and graph digests plus the
|
||||
normalized options. Local paths and timestamps do not affect that identity.
|
||||
Changing either artifact or a budget creates a new run in the same workspace;
|
||||
it does not overwrite the earlier run.
|
||||
|
||||
## Integrity and concurrency
|
||||
|
||||
A workspace contains a canonical Evidence v2 bundle plus run manifests. Each
|
||||
workspace revision has a deterministic digest and points to the prior revision
|
||||
digest. Parsing verifies:
|
||||
|
||||
- the workspace, run, Evidence, graph, and revision identities;
|
||||
- canonical ordering and unique run IDs;
|
||||
- every run-to-Evidence reference;
|
||||
- stage and completion-state consistency.
|
||||
|
||||
Writes are canonical JSON, atomic, fsynced, and mode `0600`. REA rejects
|
||||
workspace paths outside `REA_EVIDENCE_ROOTS_JSON`, symlink destinations,
|
||||
oversized or deeply nested JSON, stale expected revisions, and concurrent lock
|
||||
holders. Lock or CAS conflicts fail without mutating the workspace; callers may
|
||||
retry the same idempotent request.
|
||||
|
||||
## Truth boundary
|
||||
|
||||
The automatic run currently observes shipped artifact structure. Artifact
|
||||
changes are static behavior candidates, not runtime observations or causal
|
||||
claims. Without controlled process comparison Evidence, `behavior_status`
|
||||
remains `unknown` even when static differences are present. Provider
|
||||
limitations, incomplete comparison pages, and unsupported container expansion
|
||||
remain explicit in the final Evidence.
|
||||
|
||||
Automatic Hopper function matching, process replay, protocols, UI comparison,
|
||||
and reconstruction verification are not yet stages of this run. Existing
|
||||
manual comparison Evidence can still be aggregated through the original
|
||||
`find_changed_behavior` mode.
|
||||
|
|
@ -131,12 +131,7 @@ all three conditions:
|
|||
2. `integrity_policy=record-and-continue`;
|
||||
3. explicit per-call `integrity_continue_approved=true`.
|
||||
|
||||
Contradictory bytes are quarantined from nested expansion, recorded with declared
|
||||
and observed hashes, trust, provenance, path, and unpacked state, and bounded by
|
||||
`max_integrity_mismatches`. Verified siblings continue. Comparisons classify the
|
||||
result as a contradiction and reconstruction cannot treat it as unchanged.
|
||||
`investigate-versions` accepts the same policy through
|
||||
`--integrity-policy record-and-continue --integrity-continue-approved` and
|
||||
`--max-integrity-mismatches`. Its Evidence pages and CAS-linked workspace retain
|
||||
contradictions, so a completed run can resume without rescanning or weakening
|
||||
trust labels.
|
||||
Contradictory bytes are quarantined from nested expansion and recorded with
|
||||
declared and observed hashes, trust, provenance, path, and unpacked state.
|
||||
Verified siblings continue. Comparisons classify the result as a contradiction
|
||||
and reconstruction cannot treat it as unchanged.
|
||||
|
|
|
|||
|
|
@ -429,7 +429,7 @@
|
|||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 68,
|
||||
"primary_count": 67,
|
||||
"commands": [
|
||||
"analyze",
|
||||
"analyze-javascript-application",
|
||||
|
|
@ -474,7 +474,6 @@
|
|||
"inspect-web-page",
|
||||
"instructions",
|
||||
"inventory-artifact",
|
||||
"investigate-versions",
|
||||
"list-architectures",
|
||||
"list-browser-targets",
|
||||
"list-electron-targets",
|
||||
|
|
@ -509,14 +508,14 @@
|
|||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 68,
|
||||
"cli_commands": 67,
|
||||
"mcp_tools": 115,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "4722f1a2fe1d16d493e4a1f51450376e2cd0c617b9659d292a61908d8bb05fcf",
|
||||
"tools_sha256": "c0b0319310502e55dd3279361c5a65615a12f25c0e97fb0779b79b7950cecfe0",
|
||||
"prompts_sha256": "77c76df62f92484a43cebf5ca110bdf562c38fbee2531d293eeb51d6e712d459",
|
||||
"combined_sha256": "94f7cd1ac629a6bbffa77ba3b3d3f2e208e15df821bc0b14db4f81692d6f8eaa",
|
||||
"combined_sha256": "85d3e5545b3e01eb22b67a1794fdcb737654a1abbea803e1f2445815ee4f17ff",
|
||||
"providers_sha256": "7446b51788734ebf6f4f702e3a5a8c9f0b61967490c245f30bd37b2060827e6b"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ const SOURCE_PATHS = {
|
|||
processCapture: "dist/domain/processCapture.js",
|
||||
analysisSnapshot: "dist/domain/analysisSnapshot.js",
|
||||
artifactGraph: "dist/domain/artifactGraph.js",
|
||||
investigationWorkspace: "dist/domain/investigationWorkspace.js",
|
||||
browserObservation: "dist/domain/browserObservation.js",
|
||||
browserScenario: "dist/domain/browserScenario.js",
|
||||
browserScenarioCapture: "dist/domain/browserScenarioCapture.js",
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
/** Verify the packed CLI's persistent investigation and idempotent reuse. */
|
||||
export async function verifyPackagedInvestigation(input) {
|
||||
const artifactArchiveV2 = join(input.workspace, "artifact-v2.zip");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("app/main.js", new TextReader("main(2);"));
|
||||
await writer.add("app/new.js", new TextReader("feature();"));
|
||||
await writeFile(artifactArchiveV2, await writer.close());
|
||||
const workspacePath = join(input.evidenceRoot, "versions.json");
|
||||
const args = [
|
||||
"investigate-versions",
|
||||
input.artifactArchive,
|
||||
artifactArchiveV2,
|
||||
workspacePath,
|
||||
"--workspace-name",
|
||||
"package-versions",
|
||||
"--yes",
|
||||
"--json",
|
||||
];
|
||||
const investigated = await runJson(input.cli, args, input.environment);
|
||||
const reused = await runJson(input.cli, args, input.environment);
|
||||
let persisted = JSON.parse(await readFile(workspacePath, "utf8"));
|
||||
const runId = investigated.normalized_result?.investigation_run?.run_id;
|
||||
if (runId === undefined)
|
||||
throw new Error("packaged investigation did not return a run ID");
|
||||
await Promise.all([
|
||||
rm(input.artifactArchive, { force: true }),
|
||||
rm(artifactArchiveV2, { force: true }),
|
||||
]);
|
||||
const replayed = await runJson(
|
||||
input.cli,
|
||||
[...args.slice(0, -1), "--replay-run-id", runId, "--json"],
|
||||
input.environment,
|
||||
);
|
||||
persisted = JSON.parse(await readFile(workspacePath, "utf8"));
|
||||
if (
|
||||
investigated.operation !== "find_changed_behavior" ||
|
||||
investigated.normalized_result?.behavior_status !== "unknown" ||
|
||||
replayed.evidence_id !== investigated.evidence_id ||
|
||||
reused.evidence_id !== investigated.evidence_id ||
|
||||
persisted.revision !== 3 ||
|
||||
persisted.runs?.[0]?.status !== "complete"
|
||||
)
|
||||
throw new Error("packaged persistent investigation CLI failed");
|
||||
return {
|
||||
arguments: {
|
||||
approved: true,
|
||||
workspace_path: workspacePath,
|
||||
workspace_name: "package-versions",
|
||||
expected_workspace_revision: persisted.revision,
|
||||
replay_run_id: runId,
|
||||
left_path: input.artifactArchive,
|
||||
right_path: artifactArchiveV2,
|
||||
},
|
||||
evidenceId: investigated.evidence_id,
|
||||
workspacePath,
|
||||
revision: persisted.revision,
|
||||
};
|
||||
}
|
||||
|
||||
async function runJson(command, args, environment) {
|
||||
const output = await exec(command, args, { env: environment });
|
||||
return JSON.parse(output.stdout);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
|
@ -17,28 +16,6 @@ const verifyMcpToolsAndPrompts = async (client, mcpOptions) => {
|
|||
await prompts.verifyPromptCompletion(client, mcpOptions, false);
|
||||
};
|
||||
|
||||
const verifyMcpReplay = async (client, mcpOptions, investigationReplay) => {
|
||||
const replay = await client.callTool(
|
||||
{
|
||||
name: "find_changed_behavior",
|
||||
arguments: { investigation_run: investigationReplay.arguments },
|
||||
},
|
||||
mcpOptions,
|
||||
);
|
||||
const replayEvidence = json(prompts.mcpText(replay));
|
||||
const replayWorkspace = json(
|
||||
await readFile(investigationReplay.workspacePath, "utf8"),
|
||||
);
|
||||
if (
|
||||
replay.isError === true ||
|
||||
replayEvidence.evidence_id !== investigationReplay.evidenceId ||
|
||||
replayWorkspace.revision !== investigationReplay.revision
|
||||
)
|
||||
throw new Error(
|
||||
"packaged MCP did not replay with workspace-only authority",
|
||||
);
|
||||
};
|
||||
|
||||
const verifyMcpTargetFree = async (client, mcpOptions) => {
|
||||
const status = await client.callTool(
|
||||
{ name: "binary_session", arguments: {} },
|
||||
|
|
@ -179,12 +156,7 @@ const verifyMcpEvidenceBundle = async (client, mcpOptions, evidenceRoot) => {
|
|||
};
|
||||
|
||||
/** Connect to the packaged MCP server and exercise the target-free catalog. */
|
||||
export async function verifyPackageMcp({
|
||||
cli,
|
||||
environment,
|
||||
evidenceRoot,
|
||||
investigationReplay,
|
||||
}) {
|
||||
export async function verifyPackageMcp({ cli, environment, evidenceRoot }) {
|
||||
const diagnosed = json(
|
||||
(
|
||||
await execute(cli, ["mcp", "doctor", "--json"], {
|
||||
|
|
@ -216,7 +188,6 @@ export async function verifyPackageMcp({
|
|||
await client.connect(transport);
|
||||
const mcpOptions = { timeout: 15_000 };
|
||||
await verifyMcpToolsAndPrompts(client, mcpOptions);
|
||||
await verifyMcpReplay(client, mcpOptions, investigationReplay);
|
||||
await verifyMcpTargetFree(client, mcpOptions);
|
||||
await verifyMcpUnknownProvider(client, mcpOptions);
|
||||
await verifyMcpBinaryLifecycle(client, mcpOptions);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
|||
import { PRODUCT_IDENTITY } from "../dist/identity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
|
||||
import * as prompts from "./verify-package-prompts.mjs";
|
||||
import { verifyPackagedInvestigation } from "./verify-package-investigation.mjs";
|
||||
import { verifyPackageArtifactAndElectron } from "./verify-package-artifact.mjs";
|
||||
import { verifyPackageCapabilitiesAndSearch } from "./verify-package-capabilities.mjs";
|
||||
import { verifyPackageDiscovery } from "./verify-package-discovery.mjs";
|
||||
|
|
@ -70,7 +69,7 @@ try {
|
|||
cli,
|
||||
environment: environmentData.environment,
|
||||
});
|
||||
const { artifactArchive } = await verifyPackageArtifactAndElectron({
|
||||
await verifyPackageArtifactAndElectron({
|
||||
cli,
|
||||
workspace,
|
||||
environment: environmentData.environment,
|
||||
|
|
@ -80,13 +79,6 @@ try {
|
|||
workspace,
|
||||
environment: environmentData.environment,
|
||||
});
|
||||
const investigationReplay = await verifyPackagedInvestigation({
|
||||
cli,
|
||||
workspace,
|
||||
evidenceRoot,
|
||||
artifactArchive,
|
||||
environment: environmentData.environment,
|
||||
});
|
||||
await verifyUnknownProvider({
|
||||
cli,
|
||||
environment: environmentData.environment,
|
||||
|
|
@ -123,7 +115,6 @@ try {
|
|||
cli,
|
||||
environment: environmentData.environment,
|
||||
evidenceRoot,
|
||||
investigationReplay,
|
||||
});
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedRuntimePlanCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 3 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true, mcpStartup, mcpModuleLoading })}\n`,
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,313 +0,0 @@
|
|||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { runCrossVersionInvestigation } from "../../../src/application/CrossVersionInvestigation.js";
|
||||
import {
|
||||
readInvestigationWorkspace,
|
||||
writeInvestigationWorkspace,
|
||||
} from "../../../src/application/InvestigationWorkspaceStore.js";
|
||||
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js";
|
||||
import {
|
||||
createInvestigationWorkspace,
|
||||
crossVersionInvestigationInputSchema,
|
||||
investigationRunSchema,
|
||||
} from "../../../src/domain/investigationWorkspace.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
|
||||
const policy = (root: string): EvidenceFilePolicy => ({
|
||||
roots: [root],
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
});
|
||||
|
||||
const fixture = async () => {
|
||||
const directory = await createTestTempDirectory("rea-workspace-");
|
||||
const left = join(directory, "left");
|
||||
const right = join(directory, "right");
|
||||
await Promise.all([mkdir(left), mkdir(right)]);
|
||||
await Promise.all([
|
||||
writeFile(join(left, "app.txt"), "version one\n"),
|
||||
writeFile(join(right, "app.txt"), "version two\n"),
|
||||
writeFile(join(right, "added.txt"), "new behavior candidate\n"),
|
||||
]);
|
||||
const path = join(directory, "workspace.json");
|
||||
const input = crossVersionInvestigationInputSchema.parse({
|
||||
approved: true,
|
||||
workspace_path: path,
|
||||
workspace_name: "release-diff",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 2, change_limit: 100 },
|
||||
});
|
||||
return { directory, left, right, path, input };
|
||||
};
|
||||
|
||||
describe("persistent cross-version investigation workspace", () => {
|
||||
it("uses valid workspace roots when another configured root is missing", async () => {
|
||||
const root = await createTestTempDirectory("rea-workspace-stale-root-");
|
||||
const path = join(root, "workspace.json");
|
||||
const workspace = createInvestigationWorkspace(
|
||||
"stale-root",
|
||||
createEvidenceBundle([]),
|
||||
[],
|
||||
);
|
||||
const configured = {
|
||||
...policy(root),
|
||||
roots: [join(root, "missing"), root],
|
||||
};
|
||||
expect(
|
||||
await writeInvestigationWorkspace(workspace, path, null, configured),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(await readInvestigationWorkspace(path, configured)).toEqual({
|
||||
ok: true,
|
||||
value: workspace,
|
||||
});
|
||||
});
|
||||
|
||||
it("stores workspaces in dot-prefixed child directories", async () => {
|
||||
const root = await createTestTempDirectory("rea-workspace-dot-child-");
|
||||
const directory = join(root, "..cache");
|
||||
await mkdir(directory);
|
||||
const path = join(directory, "workspace.json");
|
||||
const workspace = createInvestigationWorkspace(
|
||||
"dot-child",
|
||||
createEvidenceBundle([]),
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
await writeInvestigationWorkspace(workspace, path, null, policy(root)),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(await readInvestigationWorkspace(path, policy(root))).toEqual({
|
||||
ok: true,
|
||||
value: workspace,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces shared JSON depth limits while reading workspaces", async () => {
|
||||
const { directory, path, input } = await fixture();
|
||||
const completed = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
if (!completed.ok) throw completed.error;
|
||||
|
||||
await expect(
|
||||
readInvestigationWorkspace(path, {
|
||||
...policy(directory),
|
||||
maxDepth: 1,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "InvestigationWorkspaceError", reason: "too-large" },
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes from a comparison checkpoint without recomputing its identity", async () => {
|
||||
const { directory, input } = await fixture();
|
||||
const completed = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
if (!completed.ok) throw completed.error;
|
||||
const run = completed.value.workspace.runs[0];
|
||||
if (run === undefined || run.comparison_evidence_id === null)
|
||||
throw new Error("missing completed run");
|
||||
expect(
|
||||
investigationRunSchema.safeParse({ ...run, status: "running" }).success,
|
||||
).toBe(false);
|
||||
const partialRun = investigationRunSchema.parse({
|
||||
...run,
|
||||
status: "running",
|
||||
completed_stages: [
|
||||
"inventory_left",
|
||||
"inventory_right",
|
||||
"compare_artifacts",
|
||||
],
|
||||
result_evidence_id: null,
|
||||
});
|
||||
const records = completed.value.workspace.bundle.records.filter(
|
||||
({ evidence_id: id }) => id !== run.result_evidence_id,
|
||||
);
|
||||
const partial = createInvestigationWorkspace(
|
||||
"release-diff",
|
||||
createEvidenceBundle(records),
|
||||
[partialRun],
|
||||
);
|
||||
const resumePath = join(directory, "resume.json");
|
||||
expect(
|
||||
await writeInvestigationWorkspace(
|
||||
partial,
|
||||
resumePath,
|
||||
null,
|
||||
policy(directory),
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
|
||||
const replayAuthorization = vi.fn(() => Promise.resolve(ok(null)));
|
||||
await expect(
|
||||
runCrossVersionInvestigation(
|
||||
{
|
||||
...input,
|
||||
workspace_path: resumePath,
|
||||
replay_run_id: partialRun.run_id,
|
||||
},
|
||||
policy(directory),
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "InvestigationWorkspaceError",
|
||||
reason: "revision-conflict",
|
||||
},
|
||||
});
|
||||
expect(replayAuthorization).not.toHaveBeenCalled();
|
||||
|
||||
const resumed = await runCrossVersionInvestigation(
|
||||
{ ...input, workspace_path: resumePath },
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
expect(resumed).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
reused: false,
|
||||
evidence: { evidence_id: completed.value.evidence.evidence_id },
|
||||
workspace: { revision: 2 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistent investigation workspace history", () => {
|
||||
it("appends a changed-content run without replacing earlier Evidence", async () => {
|
||||
const { directory, right, input } = await fixture();
|
||||
const first = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
if (!first.ok) throw first.error;
|
||||
const firstRun = first.value.workspace.runs[0];
|
||||
if (firstRun === undefined || firstRun.result_evidence_id === null)
|
||||
throw new Error("missing first completed run");
|
||||
|
||||
await writeFile(join(right, "app.txt"), "version three\n");
|
||||
const second = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
expect(second).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
reused: false,
|
||||
workspace: {
|
||||
revision: 6,
|
||||
runs: [{ status: "complete" }, { status: "complete" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!second.ok) throw second.error;
|
||||
expect(second.value.workspace.runs).toHaveLength(2);
|
||||
expect(second.value.workspace.runs.map(({ run_id: id }) => id)).toContain(
|
||||
firstRun.run_id,
|
||||
);
|
||||
expect(
|
||||
second.value.workspace.bundle.records.some(
|
||||
({ evidence_id: id }) => id === firstRun.result_evidence_id,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("checkpoints bounded integrity contradictions and safely reuses them", async () => {
|
||||
const directory = await createTestTempDirectory("rea-workspace-integrity-");
|
||||
const source = join(directory, "source");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "addon.node"), "verified native addon\n");
|
||||
const left = join(directory, "left.asar");
|
||||
const right = join(directory, "right.asar");
|
||||
await Promise.all([
|
||||
createPackageWithOptions(source, left, { unpack: "*.node" }),
|
||||
createPackageWithOptions(source, right, { unpack: "*.node" }),
|
||||
]);
|
||||
await writeFile(join(`${right}.unpacked`, "addon.node"), "tampered\n");
|
||||
const base = {
|
||||
approved: true as const,
|
||||
workspace_path: join(directory, "strict.json"),
|
||||
workspace_name: "integrity-diff",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 500, change_limit: 100 },
|
||||
};
|
||||
const strict = await runCrossVersionInvestigation(
|
||||
crossVersionInvestigationInputSchema.parse(base),
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
expect(strict).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "integrity" },
|
||||
});
|
||||
|
||||
const continuedInput = crossVersionInvestigationInputSchema.parse({
|
||||
...base,
|
||||
workspace_path: join(directory, "continued.json"),
|
||||
integrity_policy: "record-and-continue",
|
||||
integrity_continue_approved: true,
|
||||
max_integrity_mismatches: 2,
|
||||
});
|
||||
const continued = await runCrossVersionInvestigation(
|
||||
continuedInput,
|
||||
policy(directory),
|
||||
{ integrityContinueEnabled: true },
|
||||
);
|
||||
expect(continued).toMatchObject({
|
||||
ok: true,
|
||||
value: { reused: false, workspace: { revision: 3 } },
|
||||
});
|
||||
if (!continued.ok) throw continued.error;
|
||||
expect(
|
||||
continued.value.workspace.bundle.records.some((record) =>
|
||||
JSON.stringify(record.normalized_result).includes(
|
||||
'"integrity_contradictions":[{',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
continued.value.workspace.bundle.records.find(
|
||||
({ operation }) => operation === "compare_artifacts",
|
||||
)?.normalized_result,
|
||||
).toMatchObject({ status: "contradiction" });
|
||||
await expect(
|
||||
runCrossVersionInvestigation(continuedInput, policy(directory), {
|
||||
integrityContinueEnabled: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, value: { reused: true } });
|
||||
|
||||
const strictAfterContinuation = crossVersionInvestigationInputSchema.parse({
|
||||
...base,
|
||||
workspace_path: continuedInput.workspace_path,
|
||||
});
|
||||
await expect(
|
||||
runCrossVersionInvestigation(
|
||||
strictAfterContinuation,
|
||||
policy(directory),
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "integrity" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { runCrossVersionInvestigation } from "../../../src/application/CrossVersionInvestigation.js";
|
||||
import { readInvestigationWorkspace } from "../../../src/application/InvestigationWorkspaceStore.js";
|
||||
import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js";
|
||||
import { crossVersionInvestigationInputSchema } from "../../../src/domain/investigationWorkspace.js";
|
||||
|
||||
const policy = (root: string): EvidenceFilePolicy => ({
|
||||
roots: [root],
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
});
|
||||
|
||||
const fixture = async () => {
|
||||
const directory = await createTestTempDirectory("rea-workspace-");
|
||||
const left = join(directory, "left");
|
||||
const right = join(directory, "right");
|
||||
await Promise.all([mkdir(left), mkdir(right)]);
|
||||
await Promise.all([
|
||||
writeFile(join(left, "app.txt"), "version one\n"),
|
||||
writeFile(join(right, "app.txt"), "version two\n"),
|
||||
writeFile(join(right, "added.txt"), "new behavior candidate\n"),
|
||||
]);
|
||||
const path = join(directory, "workspace.json");
|
||||
const input = crossVersionInvestigationInputSchema.parse({
|
||||
approved: true,
|
||||
workspace_path: path,
|
||||
workspace_name: "release-diff",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 2, change_limit: 100 },
|
||||
});
|
||||
return { directory, left, right, path, input };
|
||||
};
|
||||
|
||||
describe("persistent cross-version investigation workspace", () => {
|
||||
it("fails closed on locks, CAS conflicts, tampering, and cancellation", async () => {
|
||||
const { directory, path, input } = await fixture();
|
||||
await writeFile(`${path}.lock`, `${String(process.pid)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
expect(
|
||||
await runCrossVersionInvestigation(input, policy(directory), {}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "InvestigationWorkspaceError", reason: "locked" },
|
||||
});
|
||||
await rm(`${path}.lock`);
|
||||
await writeFile(`${path}.lock`, "occupied\n", { mode: 0o600 });
|
||||
expect(
|
||||
await runCrossVersionInvestigation(input, policy(directory), {}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "InvestigationWorkspaceError", reason: "locked" },
|
||||
});
|
||||
await rm(`${path}.lock`);
|
||||
await writeFile(`${path}.lock`, "2147483647\n", { mode: 0o600 });
|
||||
const completed = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
if (!completed.ok) throw completed.error;
|
||||
await expect(stat(`${path}.lock`)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
expect(
|
||||
await runCrossVersionInvestigation(
|
||||
{ ...input, expected_workspace_revision: 2 },
|
||||
policy(directory),
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "InvestigationWorkspaceError",
|
||||
reason: "revision-conflict",
|
||||
},
|
||||
});
|
||||
|
||||
const decoded = JSON.parse(await readFile(path, "utf8")) as unknown;
|
||||
if (typeof decoded !== "object" || decoded === null)
|
||||
throw new Error("invalid workspace fixture");
|
||||
await chmod(path, 0o600);
|
||||
await writeFile(path, JSON.stringify({ ...decoded, revision: 99 }));
|
||||
expect(
|
||||
await readInvestigationWorkspace(path, policy(directory)),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "InvestigationWorkspaceError", reason: "integrity" },
|
||||
});
|
||||
|
||||
const cancelledPath = join(directory, "cancelled.json");
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(
|
||||
await runCrossVersionInvestigation(
|
||||
{ ...input, workspace_path: cancelledPath },
|
||||
policy(directory),
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects workspace paths and symlinks outside approved roots", async () => {
|
||||
const { directory, input } = await fixture();
|
||||
const approved = join(directory, "approved");
|
||||
await mkdir(approved);
|
||||
expect(
|
||||
await runCrossVersionInvestigation(input, policy(approved), {}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "InvestigationWorkspaceError",
|
||||
reason: "outside-root",
|
||||
},
|
||||
});
|
||||
|
||||
const outside = join(directory, "outside.json");
|
||||
await writeFile(outside, "{}", { mode: 0o600 });
|
||||
const escaped = join(approved, "escaped.json");
|
||||
await symlink(outside, escaped);
|
||||
expect(
|
||||
await runCrossVersionInvestigation(
|
||||
{ ...input, workspace_path: escaped },
|
||||
policy(approved),
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "InvestigationWorkspaceError", reason: "not-file" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,379 +0,0 @@
|
|||
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { runCrossVersionInvestigation } from "../../../src/application/CrossVersionInvestigation.js";
|
||||
import {
|
||||
readInvestigationWorkspace,
|
||||
writeInvestigationWorkspace,
|
||||
} from "../../../src/application/InvestigationWorkspaceStore.js";
|
||||
import { changedBehaviorResultSchema } from "../../../src/domain/changedBehavior.js";
|
||||
import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js";
|
||||
import {
|
||||
createInvestigationWorkspace,
|
||||
crossVersionInvestigationInputSchema,
|
||||
investigationRunSchema,
|
||||
parseInvestigationWorkspace,
|
||||
serializeInvestigationWorkspace,
|
||||
} from "../../../src/domain/investigationWorkspace.js";
|
||||
|
||||
const digestCanonical = (value: unknown): string => {
|
||||
const encoded = canonicalize(value);
|
||||
if (encoded === undefined) throw new TypeError("fixture is not canonical");
|
||||
return createHash("sha256").update(encoded).digest("hex");
|
||||
};
|
||||
|
||||
const policy = (root: string): EvidenceFilePolicy => ({
|
||||
roots: [root],
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
});
|
||||
|
||||
const fixture = async () => {
|
||||
const directory = await createTestTempDirectory("rea-workspace-");
|
||||
const left = join(directory, "left");
|
||||
const right = join(directory, "right");
|
||||
await Promise.all([mkdir(left), mkdir(right)]);
|
||||
await Promise.all([
|
||||
writeFile(join(left, "app.txt"), "version one\n"),
|
||||
writeFile(join(right, "app.txt"), "version two\n"),
|
||||
writeFile(join(right, "added.txt"), "new behavior candidate\n"),
|
||||
]);
|
||||
const path = join(directory, "workspace.json");
|
||||
const input = crossVersionInvestigationInputSchema.parse({
|
||||
approved: true,
|
||||
workspace_path: path,
|
||||
workspace_name: "release-diff",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 2, change_limit: 100 },
|
||||
});
|
||||
return { directory, left, right, path, input };
|
||||
};
|
||||
|
||||
describe("persistent cross-version investigation workspace", () => {
|
||||
it("migrates legacy v1 runs without losing their revision chain", async () => {
|
||||
const { directory, input } = await fixture();
|
||||
const completed = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
if (!completed.ok) throw completed.error;
|
||||
const current = completed.value.workspace;
|
||||
const currentRun = current.runs[0];
|
||||
if (currentRun === undefined) throw new Error("missing completed run");
|
||||
expect(() =>
|
||||
investigationRunSchema.parse({
|
||||
...currentRun,
|
||||
legacy_request_identity: true,
|
||||
}),
|
||||
).toThrow(/migrated strict runs/iu);
|
||||
const legacyIdentity = digestCanonical({
|
||||
schema: "rea.cross-version-investigation-request/v1",
|
||||
left: currentRun.left,
|
||||
right: currentRun.right,
|
||||
options: currentRun.options,
|
||||
});
|
||||
expect(() =>
|
||||
investigationRunSchema.parse({
|
||||
...currentRun,
|
||||
legacy_request_identity: true,
|
||||
run_id: `run_${legacyIdentity}`,
|
||||
request_sha256: legacyIdentity,
|
||||
max_integrity_mismatches: 100,
|
||||
}),
|
||||
).toThrow(/migrated strict runs/iu);
|
||||
const legacyRuns = current.runs.map((run) => {
|
||||
const requestSha256 = digestCanonical({
|
||||
schema: "rea.cross-version-investigation-request/v1",
|
||||
left: run.left,
|
||||
right: run.right,
|
||||
options: run.options,
|
||||
});
|
||||
const {
|
||||
integrity_policy: _integrityPolicy,
|
||||
integrity_continue_approved: _integrityApproved,
|
||||
max_integrity_mismatches: _integrityLimit,
|
||||
...legacy
|
||||
} = run;
|
||||
return {
|
||||
...legacy,
|
||||
run_id: `run_${requestSha256}`,
|
||||
request_sha256: requestSha256,
|
||||
};
|
||||
});
|
||||
const legacySemantic = {
|
||||
workspace_version: 1 as const,
|
||||
workspace_id: current.workspace_id,
|
||||
name: current.name,
|
||||
revision: current.revision,
|
||||
previous_revision_digest: current.previous_revision_digest,
|
||||
bundle: current.bundle,
|
||||
runs: legacyRuns,
|
||||
};
|
||||
const legacyDigest = `wrev_${digestCanonical(legacySemantic)}`;
|
||||
expect(() =>
|
||||
parseInvestigationWorkspace({
|
||||
...legacySemantic,
|
||||
runs: legacyRuns.map((run) => ({
|
||||
...run,
|
||||
max_integrity_mismatches: 100,
|
||||
})),
|
||||
revision_digest: legacyDigest,
|
||||
}),
|
||||
).toThrow(/unrecognized key/iu);
|
||||
const migrated = parseInvestigationWorkspace({
|
||||
...legacySemantic,
|
||||
revision_digest: legacyDigest,
|
||||
});
|
||||
|
||||
expect(migrated).toMatchObject({
|
||||
revision: current.revision,
|
||||
previous_revision_digest: current.previous_revision_digest,
|
||||
runs: [
|
||||
{
|
||||
legacy_request_identity: true,
|
||||
integrity_policy: "fail",
|
||||
integrity_continue_approved: false,
|
||||
max_integrity_mismatches: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const encodedLegacy = canonicalize({
|
||||
...legacySemantic,
|
||||
revision_digest: legacyDigest,
|
||||
});
|
||||
if (encodedLegacy === undefined)
|
||||
throw new TypeError("legacy fixture is not canonical");
|
||||
await writeFile(input.workspace_path, encodedLegacy, { mode: 0o600 });
|
||||
await expect(
|
||||
runCrossVersionInvestigation(
|
||||
{ ...input, expected_workspace_revision: current.revision },
|
||||
policy(directory),
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, value: { reused: true } });
|
||||
await expect(
|
||||
runCrossVersionInvestigation(
|
||||
{
|
||||
...input,
|
||||
options: {
|
||||
...input.options,
|
||||
change_limit: input.options.change_limit + 1,
|
||||
},
|
||||
},
|
||||
policy(directory),
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, value: { reused: false } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistent investigation workspace reuse", () => {
|
||||
it("checkpoints, validates, and reuses a completed deterministic run", async () => {
|
||||
const { directory, path, input } = await fixture();
|
||||
const first = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
expect(first).toMatchObject({
|
||||
ok: true,
|
||||
value: { reused: false, workspace: { revision: 3 } },
|
||||
});
|
||||
if (!first.ok) throw first.error;
|
||||
const result = changedBehaviorResultSchema.parse(
|
||||
first.value.evidence.normalized_result,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
behavior_status: "unknown",
|
||||
summary: { static_candidates: 3 },
|
||||
investigation_run: {
|
||||
workspace_id: first.value.workspace.workspace_id,
|
||||
run_id: first.value.workspace.runs[0]?.run_id,
|
||||
},
|
||||
});
|
||||
expect(result.limitations).toContain(
|
||||
"No process comparison Evidence was supplied.",
|
||||
);
|
||||
expect(first.value.workspace.bundle.records).toHaveLength(5);
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
||||
expect(await readFile(path, "utf8")).toBe(
|
||||
serializeInvestigationWorkspace(first.value.workspace),
|
||||
);
|
||||
|
||||
const second = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
expect(second).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
reused: true,
|
||||
evidence: { evidence_id: first.value.evidence.evidence_id },
|
||||
workspace: { revision: 3 },
|
||||
},
|
||||
});
|
||||
expect(await readInvestigationWorkspace(path, policy(directory))).toEqual({
|
||||
ok: true,
|
||||
value: first.value.workspace,
|
||||
});
|
||||
});
|
||||
|
||||
it("produces stable multi-provider Evidence closure across independent version runs", async () => {
|
||||
const { directory, input } = await fixture();
|
||||
const repeatedInput = crossVersionInvestigationInputSchema.parse({
|
||||
...input,
|
||||
workspace_path: join(directory, "repeated-workspace.json"),
|
||||
});
|
||||
const [first, repeated] = await Promise.all([
|
||||
runCrossVersionInvestigation(input, policy(directory), {}),
|
||||
runCrossVersionInvestigation(repeatedInput, policy(directory), {}),
|
||||
]);
|
||||
if (!first.ok) throw first.error;
|
||||
if (!repeated.ok) throw repeated.error;
|
||||
|
||||
expect(repeated.value.evidence).toEqual(first.value.evidence);
|
||||
expect(
|
||||
[
|
||||
...new Set(
|
||||
first.value.workspace.bundle.records.map(
|
||||
({ provider }) => provider.id,
|
||||
),
|
||||
),
|
||||
].sort(),
|
||||
).toEqual([
|
||||
"rea-artifact-comparison",
|
||||
"rea-artifact-graph",
|
||||
"rea-changed-behavior",
|
||||
]);
|
||||
|
||||
const comparison = first.value.workspace.bundle.records.find(
|
||||
({ operation }) => operation === "compare_artifacts",
|
||||
);
|
||||
if (comparison === undefined)
|
||||
throw new Error("missing artifact comparison Evidence");
|
||||
const result = changedBehaviorResultSchema.parse(
|
||||
first.value.evidence.normalized_result,
|
||||
);
|
||||
const evidenceIds = new Set(
|
||||
first.value.workspace.bundle.records.map(({ evidence_id: id }) => id),
|
||||
);
|
||||
expect(result.findings.items).not.toHaveLength(0);
|
||||
for (const finding of result.findings.items) {
|
||||
expect(finding).toMatchObject({
|
||||
classification: "derived_relationship",
|
||||
source_comparison_id: comparison.evidence_id,
|
||||
});
|
||||
expect(finding.evidence_links).toContain(comparison.evidence_id);
|
||||
expect(
|
||||
finding.evidence_links.every((evidenceId) =>
|
||||
evidenceIds.has(evidenceId),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistent investigation workspace replay", () => {
|
||||
it("replays only an explicitly selected and fully verified complete run", async () => {
|
||||
const { directory, left, right, input } = await fixture();
|
||||
const completed = await runCrossVersionInvestigation(
|
||||
input,
|
||||
policy(directory),
|
||||
{},
|
||||
);
|
||||
if (!completed.ok) throw completed.error;
|
||||
const run = completed.value.workspace.runs[0];
|
||||
if (run === undefined || run.comparison_evidence_id === null)
|
||||
throw new Error("missing completed run");
|
||||
|
||||
await expect(
|
||||
runCrossVersionInvestigation(input, policy(directory), {}),
|
||||
).resolves.toMatchObject({ ok: true, value: { reused: true } });
|
||||
|
||||
const inconsistentRun = investigationRunSchema.parse({
|
||||
...run,
|
||||
result_evidence_id: run.comparison_evidence_id,
|
||||
});
|
||||
const inconsistent = createInvestigationWorkspace(
|
||||
input.workspace_name,
|
||||
completed.value.workspace.bundle,
|
||||
[inconsistentRun],
|
||||
);
|
||||
const inconsistentPath = join(directory, "inconsistent.json");
|
||||
expect(
|
||||
await writeInvestigationWorkspace(
|
||||
inconsistent,
|
||||
inconsistentPath,
|
||||
null,
|
||||
policy(directory),
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
await expect(
|
||||
runCrossVersionInvestigation(
|
||||
{
|
||||
...input,
|
||||
workspace_path: inconsistentPath,
|
||||
replay_run_id: run.run_id,
|
||||
},
|
||||
policy(directory),
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "EvidenceIntegrityError" },
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
rm(left, { recursive: true, force: true }),
|
||||
rm(right, { recursive: true, force: true }),
|
||||
]);
|
||||
await expect(
|
||||
runCrossVersionInvestigation(
|
||||
{
|
||||
...input,
|
||||
expected_workspace_revision: completed.value.workspace.revision,
|
||||
replay_run_id: run.run_id,
|
||||
},
|
||||
policy(directory),
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
reused: true,
|
||||
evidence: { evidence_id: completed.value.evidence.evidence_id },
|
||||
workspace: { revision: completed.value.workspace.revision },
|
||||
},
|
||||
});
|
||||
|
||||
for (const replayInput of [
|
||||
{ ...input, replay_run_id: `run_${"f".repeat(64)}` },
|
||||
{ ...input, right_path: `${right}-other`, replay_run_id: run.run_id },
|
||||
{
|
||||
...input,
|
||||
options: { ...input.options, change_limit: 99 },
|
||||
replay_run_id: run.run_id,
|
||||
},
|
||||
])
|
||||
await expect(
|
||||
runCrossVersionInvestigation(replayInput, policy(directory), {}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "InvestigationWorkspaceError",
|
||||
reason: "revision-conflict",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import type { BinarySession } from "../../../src/application/BinarySession.js";
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
|
||||
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
|
||||
import {
|
||||
FUNCTION_COMPARISON_EVIDENCE,
|
||||
PROCESS_CAPTURE_RECONSTRUCTION,
|
||||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
} from "../../../src/contracts/investigationExamples.js";
|
||||
import { changedBehaviorResultSchema } from "../../../src/domain/changedBehavior.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import { readInvestigationWorkspace } from "../../../src/application/InvestigationWorkspaceStore.js";
|
||||
import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js";
|
||||
import type { PermissionAuthority } from "../../../src/application/PermissionAuthority.js";
|
||||
import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js";
|
||||
|
||||
it("never retains derived evidence after request cancellation", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
const inputs = [
|
||||
{
|
||||
name: "find_changed_behavior",
|
||||
arguments: INVESTIGATION_EXAMPLES.find_changed_behavior,
|
||||
},
|
||||
{
|
||||
name: "build_call_path",
|
||||
arguments: INVESTIGATION_EXAMPLES.build_call_path,
|
||||
},
|
||||
{
|
||||
name: "correlate_static_and_runtime",
|
||||
arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime,
|
||||
},
|
||||
] as const;
|
||||
for (const evidence of [
|
||||
FUNCTION_COMPARISON_EXAMPLE.left,
|
||||
FUNCTION_COMPARISON_EXAMPLE.right,
|
||||
FUNCTION_COMPARISON_EVIDENCE,
|
||||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_CAPTURE_RECONSTRUCTION,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
])
|
||||
expect(session.recordEvidence(evidence).ok).toBe(true);
|
||||
const initialCount = session.exportEvidenceBundle().records.length;
|
||||
try {
|
||||
for (const input of inputs) {
|
||||
const controller = new AbortController();
|
||||
const request = client.callTool(input, {
|
||||
signal: controller.signal,
|
||||
onprogress: () => controller.abort(),
|
||||
});
|
||||
await expect(request).rejects.toThrow(/abort/iu);
|
||||
}
|
||||
await expect
|
||||
.poll(() => session.exportEvidenceBundle().records.length)
|
||||
.toBe(initialCount);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it("runs and reuses a persistent cross-version workspace", async () => {
|
||||
const directory = await createTestTempDirectory("rea-investigation-mcp-");
|
||||
const left = join(directory, "left");
|
||||
const right = join(directory, "right");
|
||||
const workspace = join(directory, "workspace.json");
|
||||
await Promise.all([mkdir(left), mkdir(right)]);
|
||||
await Promise.all([
|
||||
writeFile(join(left, "feature.txt"), "old\n"),
|
||||
writeFile(join(right, "feature.txt"), "new\n"),
|
||||
]);
|
||||
const filePolicy = evidencePolicy(directory);
|
||||
const { session, server, client } = await connected(filePolicy);
|
||||
try {
|
||||
const arguments_ = {
|
||||
investigation_run: {
|
||||
approved: true,
|
||||
workspace_path: workspace,
|
||||
workspace_name: "mcp-release-diff",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 500, change_limit: 100 },
|
||||
},
|
||||
};
|
||||
const first = await client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(first.isError).not.toBe(true);
|
||||
const firstEvidence = sessionEvidence(session, first.structuredContent);
|
||||
expect(firstEvidence.normalized_result).toMatchObject({
|
||||
behavior_status: "unknown",
|
||||
summary: { static_candidates: 2 },
|
||||
investigation_run: { inventory_evidence_count: 2 },
|
||||
});
|
||||
expect(session.hasEvidence(firstEvidence.evidence_id)).toBe(true);
|
||||
const loaded = await readInvestigationWorkspace(workspace, filePolicy);
|
||||
expect(loaded).toMatchObject({
|
||||
ok: true,
|
||||
value: { revision: 3, runs: [{ status: "complete" }] },
|
||||
});
|
||||
expect(firstEvidence.normalized_result).toHaveProperty(
|
||||
"investigation_run.workspace_id",
|
||||
);
|
||||
|
||||
const second = await client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(second.isError, JSON.stringify(second)).not.toBe(true);
|
||||
const secondEvidence = sessionEvidence(session, second.structuredContent);
|
||||
expect(secondEvidence.evidence_id).toBe(firstEvidence.evidence_id);
|
||||
expect(
|
||||
await readInvestigationWorkspace(workspace, filePolicy),
|
||||
).toMatchObject({ ok: true, value: { revision: 3 } });
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("replays a complete workspace without write authority", async () => {
|
||||
const directory = await createTestTempDirectory("rea-investigation-read-");
|
||||
const left = join(directory, "left");
|
||||
const right = join(directory, "right");
|
||||
const workspace = join(directory, "workspace.json");
|
||||
await Promise.all([mkdir(left), mkdir(right)]);
|
||||
await Promise.all([
|
||||
writeFile(join(left, "feature.txt"), "old\n"),
|
||||
writeFile(join(right, "feature.txt"), "new\n"),
|
||||
]);
|
||||
const filePolicy = evidencePolicy(directory);
|
||||
const arguments_ = {
|
||||
investigation_run: {
|
||||
approved: true,
|
||||
workspace_path: workspace,
|
||||
workspace_name: "read-only-replay",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 500, change_limit: 100 },
|
||||
},
|
||||
};
|
||||
const writer = await investigationAuthority(directory, true);
|
||||
const initial = await connected(filePolicy, writer);
|
||||
try {
|
||||
const created = await initial.client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(created.isError, JSON.stringify(created)).not.toBe(true);
|
||||
} finally {
|
||||
await close(initial.session, initial.server, initial.client);
|
||||
}
|
||||
|
||||
const reader = await investigationAuthority(directory, false);
|
||||
const replay = await connected(filePolicy, reader);
|
||||
try {
|
||||
const cached = await replay.client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(cached.isError, JSON.stringify(cached)).not.toBe(true);
|
||||
expect(
|
||||
await readInvestigationWorkspace(workspace, filePolicy),
|
||||
).toMatchObject({ ok: true, value: { revision: 3 } });
|
||||
|
||||
await writeFile(join(right, "feature.txt"), "changed again\n");
|
||||
const denied = await replay.client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: arguments_,
|
||||
});
|
||||
expect(denied).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: {
|
||||
error: {
|
||||
code: "permission_required",
|
||||
details: { capability: "investigation_workspace_write" },
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await close(replay.session, replay.server, replay.client);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("defers input permission for an explicit replay with deleted inputs", async () => {
|
||||
const directory = await createTestTempDirectory("rea-investigation-replay-");
|
||||
const left = join(directory, "left");
|
||||
const right = join(directory, "right");
|
||||
const workspace = join(directory, "workspace.json");
|
||||
await Promise.all([mkdir(left), mkdir(right)]);
|
||||
await Promise.all([
|
||||
writeFile(join(left, "feature.txt"), "old\n"),
|
||||
writeFile(join(right, "feature.txt"), "new\n"),
|
||||
]);
|
||||
const filePolicy = evidencePolicy(directory);
|
||||
const investigationRun = {
|
||||
approved: true,
|
||||
workspace_path: workspace,
|
||||
workspace_name: "selected-replay",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 500, change_limit: 100 },
|
||||
};
|
||||
const writer = await investigationAuthority(directory, true);
|
||||
const initial = await connected(filePolicy, writer);
|
||||
let runId: string | undefined;
|
||||
try {
|
||||
const created = await initial.client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: { investigation_run: investigationRun },
|
||||
});
|
||||
expect(created.isError, JSON.stringify(created)).not.toBe(true);
|
||||
const evidence = sessionEvidence(
|
||||
initial.session,
|
||||
created.structuredContent,
|
||||
);
|
||||
const result = changedBehaviorResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
runId = result.investigation_run?.run_id;
|
||||
} finally {
|
||||
await close(initial.session, initial.server, initial.client);
|
||||
}
|
||||
if (runId === undefined) throw new Error("missing completed run ID");
|
||||
|
||||
await Promise.all([
|
||||
rm(left, { recursive: true, force: true }),
|
||||
rm(right, { recursive: true, force: true }),
|
||||
]);
|
||||
const workspaceOnly = await permissionAuthorityForRoot(
|
||||
directory,
|
||||
["investigation_workspace_read", "investigation_workspace_write"],
|
||||
["investigation_workspace_read"],
|
||||
);
|
||||
const replay = await connected(filePolicy, workspaceOnly);
|
||||
try {
|
||||
const cached = await replay.client.callTool({
|
||||
name: "find_changed_behavior",
|
||||
arguments: {
|
||||
investigation_run: {
|
||||
...investigationRun,
|
||||
replay_run_id: runId,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cached.isError, JSON.stringify(cached)).not.toBe(true);
|
||||
expect(
|
||||
await readInvestigationWorkspace(workspace, filePolicy),
|
||||
).toMatchObject({ ok: true, value: { revision: 3 } });
|
||||
} finally {
|
||||
await close(replay.session, replay.server, replay.client);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const connected = async (
|
||||
evidenceFilePolicy?: EvidenceFilePolicy,
|
||||
permissionAuthority?: PermissionAuthority,
|
||||
) => {
|
||||
const session = createTestBinarySession(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session, {
|
||||
...(evidenceFilePolicy === undefined ? {} : { evidenceFilePolicy }),
|
||||
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
|
||||
});
|
||||
const client = new Client({ name: "investigation-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return { session, server, client };
|
||||
};
|
||||
|
||||
const evidencePolicy = (root: string): EvidenceFilePolicy => ({
|
||||
roots: [root],
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
});
|
||||
|
||||
const sessionEvidence = (session: BinarySession, value: unknown) => {
|
||||
const parsed = z
|
||||
.object({ evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u) })
|
||||
.parse(value);
|
||||
const evidence = session.evidenceById(parsed.evidence_id);
|
||||
if (evidence === undefined) throw new TypeError("Missing session Evidence");
|
||||
return evidence;
|
||||
};
|
||||
|
||||
const investigationAuthority = async (
|
||||
root: string,
|
||||
includeWrite: boolean,
|
||||
): Promise<PermissionAuthority> =>
|
||||
permissionAuthorityForRoot(
|
||||
root,
|
||||
["investigation_workspace_read", "investigation_workspace_write"],
|
||||
[
|
||||
"investigation_workspace_read",
|
||||
...(includeWrite ? (["investigation_workspace_write"] as const) : []),
|
||||
],
|
||||
);
|
||||
|
||||
const close = async (
|
||||
session: BinarySession,
|
||||
server: Awaited<ReturnType<typeof createServer>>,
|
||||
client: Client,
|
||||
) => {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
};
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js";
|
||||
import {
|
||||
scanVersionInventories,
|
||||
type VersionInventoryScanner,
|
||||
} from "../../../src/application/CrossVersionInventory.js";
|
||||
|
||||
const LIMITS = {
|
||||
maxEntries: 10,
|
||||
maxTotalBytes: 1_024,
|
||||
maxEntryBytes: 512,
|
||||
maxCompressionRatio: 10,
|
||||
maxDepth: 8,
|
||||
maxPathBytes: 256,
|
||||
} as const;
|
||||
|
||||
const INTEGRITY = {
|
||||
mode: "fail",
|
||||
} as const;
|
||||
|
||||
const deferred = <Value>() => {
|
||||
let resolvePromise: (value: Value) => void = () => undefined;
|
||||
let rejectPromise: (reason: unknown) => void = () => undefined;
|
||||
const promise = new Promise<Value>((resolve, reject) => {
|
||||
resolvePromise = resolve;
|
||||
rejectPromise = reject;
|
||||
});
|
||||
return { promise, resolve: resolvePromise, reject: rejectPromise };
|
||||
};
|
||||
|
||||
describe("cross-version inventory", () => {
|
||||
it("starts both scans together and preserves left/right result order", async () => {
|
||||
const root = await createTestTempDirectory("rea-version-inventory-");
|
||||
const artifact = join(root, "artifact.js");
|
||||
await writeFile(artifact, "export const value = 1;\n");
|
||||
const snapshot = await scanArtifactInventory(artifact, LIMITS);
|
||||
const left = deferred<typeof snapshot>();
|
||||
const right = deferred<typeof snapshot>();
|
||||
const calls: string[] = [];
|
||||
const controller = new AbortController();
|
||||
const scanner: VersionInventoryScanner = (...arguments_) => {
|
||||
const [path, limits, options] = arguments_;
|
||||
if (options === undefined) throw new Error("Expected scanner options");
|
||||
calls.push(path);
|
||||
expect(limits).toBe(LIMITS);
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
expect(options.integrity).toBe(INTEGRITY);
|
||||
return path === "left" ? left.promise : right.promise;
|
||||
};
|
||||
|
||||
const result = scanVersionInventories(
|
||||
{
|
||||
leftPath: "left",
|
||||
rightPath: "right",
|
||||
limits: LIMITS,
|
||||
signal: controller.signal,
|
||||
integrity: INTEGRITY,
|
||||
},
|
||||
scanner,
|
||||
);
|
||||
|
||||
expect(calls).toEqual(["left", "right"]);
|
||||
right.resolve(snapshot);
|
||||
left.resolve(snapshot);
|
||||
await expect(result).resolves.toEqual({
|
||||
left: snapshot,
|
||||
right: snapshot,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects promptly when either parallel scan fails", async () => {
|
||||
const left = deferred<never>();
|
||||
const failure = new Error("right scan failed");
|
||||
const scanner: VersionInventoryScanner = (path) =>
|
||||
path === "left" ? left.promise : Promise.reject(failure);
|
||||
|
||||
await expect(
|
||||
scanVersionInventories(
|
||||
{
|
||||
leftPath: "left",
|
||||
rightPath: "right",
|
||||
limits: LIMITS,
|
||||
integrity: INTEGRITY,
|
||||
},
|
||||
scanner,
|
||||
),
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue