refactor: simplify doctor and error projections

This commit is contained in:
morluto 2026-07-18 00:27:04 +00:00
parent 4851449f71
commit 37f4ac6d70
5 changed files with 635 additions and 484 deletions

View File

@ -6,11 +6,9 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../domain/binaryTarget.js";
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
import { probeHomebrew } from "./homebrew.js";
import {
linuxHopperBinarySupported,
linuxHopperLauncherPath,
linuxSharedLibrariesAvailable,
readLinuxDistribution,
type LinuxDistribution,
@ -25,12 +23,12 @@ import {
inspectRuntimeExecutables,
type RuntimeExecutableInventory,
} from "./RuntimeExecutableDiagnostics.js";
import {
collectDoctorDiagnostics,
doctorHealthy,
} from "./DoctorDiagnostics.js";
const execFileAsync = promisify(execFile);
const DEFAULT_HOPPER =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
/** One actionable environment diagnostic. */
export interface DoctorCheck {
readonly name: string;
@ -97,6 +95,40 @@ interface InstalledSkillIdentity {
readonly catalogDigest: string | null;
}
/** Structured result returned by the read-only doctor workflow. */
export interface DoctorReport {
readonly healthy: boolean;
readonly hopperPath?: string;
readonly providerInspections?: readonly DoctorProviderInspection[];
readonly checks: readonly DoctorCheck[];
readonly identity?: DoctorIdentity;
}
/** Installed-package, skill, client, and runtime identity observations. */
export interface DoctorIdentity {
readonly cli_package_version: string;
readonly expected_skill_version: string;
readonly sdk: typeof SDK_IDENTITY;
readonly catalog: typeof CATALOG_IDENTITY;
readonly live_server: {
readonly state: "unknown";
readonly remediation: string;
};
readonly installations: {
readonly paths: readonly string[];
readonly state: "single" | "multiple" | "unknown";
};
readonly skill: {
readonly installed_version: string | null;
readonly installed_tool_count: number | null;
readonly installed_catalog_digest: string | null;
readonly state: "aligned" | "stale" | "missing";
readonly remediation: string | null;
};
readonly registrations: readonly ClientRegistrationStatus[];
readonly runtime_executables: RuntimeExecutableInventory | null;
}
/**
* Check requirements and an optional target without mutating the host.
* Every failed check includes remediation suitable for either the one-shot CLI
@ -105,221 +137,15 @@ interface InstalledSkillIdentity {
export const runDoctor = async (
target?: string,
host: DoctorHost = systemDoctorHost(),
): Promise<{
readonly healthy: boolean;
readonly hopperPath?: string;
readonly providerInspections?: readonly DoctorProviderInspection[];
readonly checks: readonly DoctorCheck[];
readonly identity?: {
readonly cli_package_version: string;
readonly expected_skill_version: string;
readonly sdk: typeof SDK_IDENTITY;
readonly catalog: typeof CATALOG_IDENTITY;
readonly live_server: {
readonly state: "unknown";
readonly remediation: string;
};
readonly installations: {
readonly paths: readonly string[];
readonly state: "single" | "multiple" | "unknown";
};
readonly skill: {
readonly installed_version: string | null;
readonly installed_tool_count: number | null;
readonly installed_catalog_digest: string | null;
readonly state: "aligned" | "stale" | "missing";
readonly remediation: string | null;
};
readonly registrations: readonly ClientRegistrationStatus[];
readonly runtime_executables: RuntimeExecutableInventory | null;
};
}> => {
const checks: DoctorCheck[] = [];
checks.push(
check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, {
remediation: "Install Node.js 22.19+ or 24.11+.",
classification: "missing_dependency",
}),
);
const runtimeExecutables = await host.runtimeExecutables?.();
if (runtimeExecutables !== undefined) {
const broken = runtimeExecutables.candidates.filter(
({ healthy }) => !healthy,
);
checks.push(
check(
"node-toolchains",
broken.length === 0,
broken.length === 0
? `${String(runtimeExecutables.candidates.length)} runtime executable candidates passed bounded version probes.`
: `${String(broken.length)} of ${String(runtimeExecutables.candidates.length)} runtime executable candidates failed bounded version probes: ${broken
.slice(0, 5)
.map(({ lexical_path: path }) => path)
.join(", ")}${broken.length > 5 ? ", …" : ""}`,
{
remediation:
"Select a verified healthy Node toolchain or repair the failing installation, then rerun rea doctor. Do not create compatibility-library symlinks.",
classification: "missing_dependency",
},
),
);
}
const macosVersion =
host.platform === "darwin" ? await host.macosVersion() : undefined;
const macosMajor = macosVersion === undefined ? 0 : parseMajor(macosVersion);
const linuxDistribution =
host.platform === "linux" ? await host.linuxDistribution() : undefined;
const supportedHost =
(host.platform === "darwin" && macosMajor >= 12) ||
(host.platform === "linux" && linuxDistribution?.supported === true) ||
(host.platform === "win32" && host.architecture === "x64");
checks.push(
check(
"host",
supportedHost,
macosVersion ??
(linuxDistribution === undefined
? `${host.platform} ${host.architecture}`
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`),
{
remediation:
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and the experimental Windows x64 Ghidra P0 boundary.",
classification: "unsupported_host",
},
),
);
const candidates =
host.configuredHopperPath === undefined
? [
DEFAULT_HOPPER,
SYSTEM_LINUX_HOPPER,
linuxHopperLauncherPath(homedir()),
...(await host.manualHopperPaths()),
]
: [host.configuredHopperPath];
let hopperPath = await firstExecutable(host, candidates);
if (hopperPath === undefined && host.configuredHopperPath === undefined) {
const brewCandidate = await host.brewHopperPath();
hopperPath = await firstExecutable(
host,
brewCandidate === undefined ? [] : [brewCandidate],
);
}
checks.push(
check(
"hopper",
hopperPath !== undefined,
hopperPath ?? host.configuredHopperPath,
{
remediation:
host.configuredHopperPath === undefined
? "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH."
: "Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.",
classification:
host.configuredHopperPath === undefined
? "missing_analysis_engine"
: "config_drift",
},
),
);
if (host.platform === "linux" && hopperPath !== undefined)
checks.push(
check(
"hopper-version",
await host.supportedLinuxHopper(hopperPath),
hopperPath,
{
remediation: unsupportedHopperRemediation(
host.configuredHopperPath === hopperPath,
),
classification: "config_drift",
},
),
);
const providerInspections = await inspectDoctorProviders(host, checks);
const javascriptReplayCheck = await host.javascriptReplayCheck?.();
if (javascriptReplayCheck !== undefined) checks.push(javascriptReplayCheck);
if (host.configuredIlspyCmdPath !== undefined) {
const version = await host.ilspyCmdVersion?.(host.configuredIlspyCmdPath);
checks.push(
check(
"ilspycmd",
version !== undefined,
version === undefined
? host.configuredIlspyCmdPath
: `${host.configuredIlspyCmdPath} (${version})`,
{
remediation:
"Unset REA_ILSPY_CMD_PATH or point it at a runnable ilspycmd executable.",
classification: "config_drift",
},
),
);
}
if (host.platform === "linux" && hopperPath !== undefined)
checks.push(
check(
"hopper-demo-runtime",
await host.linuxDemoRuntimeReady(),
undefined,
{
remediation:
"Rerun rea setup to install the Xvfb, xauth, Python, X11, and XTEST packages required for Linux demo sessions.",
classification: "missing_dependency",
},
),
);
if (target !== undefined)
checks.push(
check("target", await host.validTarget(target), target, {
remediation: "Supply a readable local app or program path.",
classification: "config_drift",
}),
);
const installationPaths = (await host.installationPaths?.()) ?? [];
const observedSkillIdentity = await host.installedSkillIdentity?.();
const legacySkillVersion =
observedSkillIdentity === undefined
? await host.installedSkillVersion?.()
: undefined;
const installedSkillIdentity =
observedSkillIdentity ??
(legacySkillVersion === undefined
? undefined
: {
version: legacySkillVersion,
toolCount: null,
catalogDigest: null,
});
const installedSkillVersion = installedSkillIdentity?.version ?? undefined;
const skillAligned =
installedSkillIdentity?.version === PRODUCT_IDENTITY.skillVersion &&
installedSkillIdentity.toolCount === CATALOG_IDENTITY.counts.mcp_tools &&
installedSkillIdentity.catalogDigest ===
CATALOG_IDENTITY.digests.combined_sha256;
if (!skillAligned)
checks.push({
name: "skill:identity",
ok: false,
classification: "config_drift",
detail:
installedSkillIdentity === undefined
? "Installed REA skill identity is missing."
: "Installed REA skill identity is stale.",
remediation: "Run rea setup to update the installed REA skill.",
});
const registrations = (await host.clientRegistrations?.()) ?? [];
for (const registration of registrations)
if (registration.state !== "aligned")
checks.push({
name: `registration:${registration.client}`,
ok: false,
classification: "config_drift",
detail: registration.config_path,
remediation:
registration.remediation ??
"Run rea setup, then restart the affected client.",
});
): Promise<DoctorReport> => {
const {
checks: diagnosticChecks,
hopperPath,
providerInspections,
runtimeExecutables,
} = await collectDoctorDiagnostics(target, host);
const identity = await collectDoctorIdentity(host, runtimeExecutables);
const checks = [...diagnosticChecks, ...identity.checks];
return {
healthy: doctorHealthy(checks, {
hopperAvailable:
@ -333,7 +159,29 @@ export const runDoctor = async (
...(hopperPath === undefined ? {} : { hopperPath }),
...(providerInspections === undefined ? {} : { providerInspections }),
checks,
identity: {
identity: identity.value,
};
};
const collectDoctorIdentity = async (
host: DoctorHost,
runtimeExecutables: RuntimeExecutableInventory | undefined,
): Promise<{
readonly checks: readonly DoctorCheck[];
readonly value: DoctorIdentity;
}> => {
const installationPaths = (await host.installationPaths?.()) ?? [];
const installedSkillIdentity = await readInstalledSkillIdentity(host);
const skillAligned = skillIdentityAligned(installedSkillIdentity);
const registrations = (await host.clientRegistrations?.()) ?? [];
return {
checks: [
...(skillAligned ? [] : [skillIdentityCheck(installedSkillIdentity)]),
...registrations
.filter(({ state }) => state !== "aligned")
.map(registrationCheck),
],
value: {
cli_package_version: PRODUCT_IDENTITY.packageVersion,
expected_skill_version: PRODUCT_IDENTITY.skillVersion,
sdk: SDK_IDENTITY,
@ -345,15 +193,10 @@ export const runDoctor = async (
},
installations: {
paths: installationPaths,
state:
installationPaths.length === 0
? "unknown"
: installationPaths.length === 1
? "single"
: "multiple",
state: installationState(installationPaths),
},
skill: {
installed_version: installedSkillVersion ?? null,
installed_version: installedSkillIdentity?.version ?? null,
installed_tool_count: installedSkillIdentity?.toolCount ?? null,
installed_catalog_digest: installedSkillIdentity?.catalogDigest ?? null,
state:
@ -372,10 +215,53 @@ export const runDoctor = async (
};
};
const unsupportedHopperRemediation = (configured: boolean): string =>
configured
? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA."
: "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.";
const readInstalledSkillIdentity = async (
host: DoctorHost,
): Promise<InstalledSkillIdentity | undefined> => {
const observed = await host.installedSkillIdentity?.();
if (observed !== undefined) return observed;
const legacyVersion = await host.installedSkillVersion?.();
return legacyVersion === undefined
? undefined
: { version: legacyVersion, toolCount: null, catalogDigest: null };
};
const skillIdentityAligned = (
identity: InstalledSkillIdentity | undefined,
): boolean =>
identity?.version === PRODUCT_IDENTITY.skillVersion &&
identity.toolCount === CATALOG_IDENTITY.counts.mcp_tools &&
identity.catalogDigest === CATALOG_IDENTITY.digests.combined_sha256;
const skillIdentityCheck = (
identity: InstalledSkillIdentity | undefined,
): DoctorCheck => ({
name: "skill:identity",
ok: false,
classification: "config_drift",
detail:
identity === undefined
? "Installed REA skill identity is missing."
: "Installed REA skill identity is stale.",
remediation: "Run rea setup to update the installed REA skill.",
});
const registrationCheck = (
registration: ClientRegistrationStatus,
): DoctorCheck => ({
name: `registration:${registration.client}`,
ok: false,
classification: "config_drift",
detail: registration.config_path,
remediation:
registration.remediation ??
"Run rea setup, then restart the affected client.",
});
const installationState = (
paths: readonly string[],
): DoctorIdentity["installations"]["state"] =>
paths.length === 0 ? "unknown" : paths.length === 1 ? "single" : "multiple";
/** Optional outer-adapter diagnostics composed without reversing dependencies. */
export interface SystemDoctorHostOptions {
@ -552,8 +438,6 @@ export const systemDoctorHost = (
clientRegistrations: () => readClientRegistrationStatuses(homedir()),
});
const parseMajor = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const uniqueLines = (value: string): string[] => [
...new Set(
value
@ -563,99 +447,5 @@ const uniqueLines = (value: string): string[] => [
),
];
const firstExecutable = async (
host: DoctorHost,
candidates: readonly string[],
): Promise<string | undefined> => {
for (const candidate of new Set(candidates))
if (await host.executable(candidate)) return candidate;
return undefined;
};
const firstIlspyVersionLine = (value: string): string | undefined =>
uniqueLines(value).find((line) => line.startsWith("ilspycmd:"));
const check = (
name: string,
ok: boolean,
detail: string | undefined,
failure: {
readonly remediation: string;
readonly classification: Exclude<DoctorCheck["classification"], "healthy">;
},
): DoctorCheck => ({
name,
ok,
classification: ok ? "healthy" : failure.classification,
...(detail === undefined ? {} : { detail }),
...(ok ? {} : { remediation: failure.remediation }),
});
const providerDoctorChecks = (
inspection: DoctorProviderInspection,
): readonly DoctorCheck[] =>
inspection.checks.map((candidate) => ({
name:
candidate.name === "configuration"
? inspection.id
: `${inspection.id}-${candidate.name}`,
ok: candidate.ok,
classification: candidate.ok ? "healthy" : candidate.classification,
detail: candidate.detail,
...(candidate.remediation === null
? {}
: { remediation: candidate.remediation }),
}));
const inspectDoctorProviders = async (
host: DoctorHost,
checks: DoctorCheck[],
): Promise<readonly DoctorProviderInspection[] | undefined> => {
const inspections = await host.providerInspections?.();
if (inspections === undefined) return undefined;
for (const inspection of inspections)
checks.push(...providerDoctorChecks(inspection));
return inspections;
};
const doctorHealthy = (
checks: readonly DoctorCheck[],
providers: {
readonly hopperAvailable: boolean;
readonly hopperConfigured: boolean;
readonly providerInspections:
| readonly DoctorProviderInspection[]
| undefined;
},
): boolean => {
if (providers.providerInspections === undefined)
return checks.every(({ ok }) => ok);
const providerChecks = checks.filter(
({ name }) =>
name.startsWith("hopper") ||
providers.providerInspections?.some((inspection) =>
providerCheckName(inspection.id, name),
) === true,
);
const coreHealthy = checks
.filter((candidate) => !providerChecks.includes(candidate))
.every(({ ok }) => ok);
const configuredProvidersHealthy = providerChecks
.filter(({ name }) => {
if (name.startsWith("hopper")) return providers.hopperConfigured;
return providers.providerInspections?.some(
(inspection) =>
inspection.configured && providerCheckName(inspection.id, name),
);
})
.every(({ ok }) => ok);
return (
coreHealthy &&
configuredProvidersHealthy &&
(providers.hopperAvailable ||
providers.providerInspections.some(({ available }) => available))
);
};
const providerCheckName = (providerId: string, name: string): boolean =>
name === providerId || name.startsWith(`${providerId}-`);

View File

@ -0,0 +1,305 @@
import { homedir } from "node:os";
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
import { linuxHopperLauncherPath } from "./LinuxHopper.js";
import type {
DoctorCheck,
DoctorHost,
DoctorProviderInspection,
} from "./Doctor.js";
import type { RuntimeExecutableInventory } from "./RuntimeExecutableDiagnostics.js";
const DEFAULT_HOPPER =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
/** Ordered environment diagnostics consumed by the Doctor report assembler. */
export interface DoctorDiagnostics {
readonly checks: readonly DoctorCheck[];
readonly hopperPath?: string;
readonly providerInspections?: readonly DoctorProviderInspection[];
readonly runtimeExecutables?: RuntimeExecutableInventory;
}
/** Collect host, provider, and optional-target diagnostics without mutation. */
export const collectDoctorDiagnostics = async (
target: string | undefined,
host: DoctorHost,
): Promise<DoctorDiagnostics> => {
const runtimeExecutables = await host.runtimeExecutables?.();
const checks: DoctorCheck[] = [
nodeCheck(host),
...runtimeChecks(runtimeExecutables),
];
checks.push(await hostCheck(host));
const hopperPath = await findHopper(host);
checks.push(hopperCheck(host, hopperPath));
const hopperVersionCheck = await optionalHopperVersionCheck(host, hopperPath);
if (hopperVersionCheck !== undefined) checks.push(hopperVersionCheck);
const providerInspections = await host.providerInspections?.();
if (providerInspections !== undefined)
checks.push(...providerInspections.flatMap(providerDoctorChecks));
const javascriptReplayCheck = await host.javascriptReplayCheck?.();
if (javascriptReplayCheck !== undefined) checks.push(javascriptReplayCheck);
const ilspyCheck = await optionalIlspyCheck(host);
if (ilspyCheck !== undefined) checks.push(ilspyCheck);
const hopperDemoCheck = await optionalHopperDemoCheck(host, hopperPath);
if (hopperDemoCheck !== undefined) checks.push(hopperDemoCheck);
const targetCheck = await optionalTargetCheck(target, host);
if (targetCheck !== undefined) checks.push(targetCheck);
return {
checks,
...(hopperPath === undefined ? {} : { hopperPath }),
...(providerInspections === undefined ? {} : { providerInspections }),
...(runtimeExecutables === undefined ? {} : { runtimeExecutables }),
};
};
/** Evaluate core health while treating unconfigured providers as optional. */
export const doctorHealthy = (
checks: readonly DoctorCheck[],
providers: {
readonly hopperAvailable: boolean;
readonly hopperConfigured: boolean;
readonly providerInspections:
| readonly DoctorProviderInspection[]
| undefined;
},
): boolean => {
if (providers.providerInspections === undefined)
return checks.every(({ ok }) => ok);
const providerChecks = checks.filter(
({ name }) =>
name.startsWith("hopper") ||
providers.providerInspections?.some((inspection) =>
providerCheckName(inspection.id, name),
) === true,
);
const coreHealthy = checks
.filter((candidate) => !providerChecks.includes(candidate))
.every(({ ok }) => ok);
const configuredProvidersHealthy = providerChecks
.filter(({ name }) => {
if (name.startsWith("hopper")) return providers.hopperConfigured;
return providers.providerInspections?.some(
(inspection) =>
inspection.configured && providerCheckName(inspection.id, name),
);
})
.every(({ ok }) => ok);
return (
coreHealthy &&
configuredProvidersHealthy &&
(providers.hopperAvailable ||
providers.providerInspections.some(({ available }) => available))
);
};
const nodeCheck = (host: DoctorHost): DoctorCheck =>
check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, {
remediation: "Install Node.js 22.19+ or 24.11+.",
classification: "missing_dependency",
});
const runtimeChecks = (
inventory: RuntimeExecutableInventory | undefined,
): readonly DoctorCheck[] => {
if (inventory === undefined) return [];
const broken = inventory.candidates.filter(({ healthy }) => !healthy);
const detail =
broken.length === 0
? `${String(inventory.candidates.length)} runtime executable candidates passed bounded version probes.`
: `${String(broken.length)} of ${String(inventory.candidates.length)} runtime executable candidates failed bounded version probes: ${broken
.slice(0, 5)
.map(({ lexical_path: path }) => path)
.join(", ")}${broken.length > 5 ? ", …" : ""}`;
return [
check("node-toolchains", broken.length === 0, detail, {
remediation:
"Select a verified healthy Node toolchain or repair the failing installation, then rerun rea doctor. Do not create compatibility-library symlinks.",
classification: "missing_dependency",
}),
];
};
const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
const macosVersion =
host.platform === "darwin" ? await host.macosVersion() : undefined;
const linuxDistribution =
host.platform === "linux" ? await host.linuxDistribution() : undefined;
const supported =
(host.platform === "darwin" && parseMajor(macosVersion) >= 12) ||
(host.platform === "linux" && linuxDistribution?.supported === true) ||
(host.platform === "win32" && host.architecture === "x64");
const detail =
macosVersion ??
(linuxDistribution === undefined
? `${host.platform} ${host.architecture}`
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`);
return check("host", supported, detail, {
remediation:
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and the experimental Windows x64 Ghidra P0 boundary.",
classification: "unsupported_host",
});
};
const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
const candidates =
host.configuredHopperPath === undefined
? [
DEFAULT_HOPPER,
SYSTEM_LINUX_HOPPER,
linuxHopperLauncherPath(homedir()),
...(await host.manualHopperPaths()),
]
: [host.configuredHopperPath];
const hopperPath = await firstExecutable(host, candidates);
if (hopperPath !== undefined || host.configuredHopperPath !== undefined)
return hopperPath;
const brewCandidate = await host.brewHopperPath();
return firstExecutable(
host,
brewCandidate === undefined ? [] : [brewCandidate],
);
};
const firstExecutable = async (
host: DoctorHost,
candidates: readonly string[],
): Promise<string | undefined> => {
for (const candidate of new Set(candidates))
if (await host.executable(candidate)) return candidate;
return undefined;
};
const hopperCheck = (
host: DoctorHost,
hopperPath: string | undefined,
): DoctorCheck =>
check(
"hopper",
hopperPath !== undefined,
hopperPath ?? host.configuredHopperPath,
{
remediation:
host.configuredHopperPath === undefined
? "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH."
: "Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.",
classification:
host.configuredHopperPath === undefined
? "missing_analysis_engine"
: "config_drift",
},
);
const optionalHopperVersionCheck = async (
host: DoctorHost,
hopperPath: string | undefined,
): Promise<DoctorCheck | undefined> => {
if (host.platform !== "linux" || hopperPath === undefined) return undefined;
return check(
"hopper-version",
await host.supportedLinuxHopper(hopperPath),
hopperPath,
{
remediation: unsupportedHopperRemediation(
host.configuredHopperPath === hopperPath,
),
classification: "config_drift",
},
);
};
const optionalHopperDemoCheck = async (
host: DoctorHost,
hopperPath: string | undefined,
): Promise<DoctorCheck | undefined> =>
host.platform !== "linux" || hopperPath === undefined
? undefined
: check(
"hopper-demo-runtime",
await host.linuxDemoRuntimeReady(),
undefined,
{
remediation:
"Rerun rea setup to install the Xvfb, xauth, Python, X11, and XTEST packages required for Linux demo sessions.",
classification: "missing_dependency",
},
);
const optionalIlspyCheck = async (
host: DoctorHost,
): Promise<DoctorCheck | undefined> => {
if (host.configuredIlspyCmdPath === undefined) return undefined;
const version = await host.ilspyCmdVersion?.(host.configuredIlspyCmdPath);
return check(
"ilspycmd",
version !== undefined,
version === undefined
? host.configuredIlspyCmdPath
: `${host.configuredIlspyCmdPath} (${version})`,
{
remediation:
"Unset REA_ILSPY_CMD_PATH or point it at a runnable ilspycmd executable.",
classification: "config_drift",
},
);
};
const optionalTargetCheck = async (
target: string | undefined,
host: DoctorHost,
): Promise<DoctorCheck | undefined> =>
target === undefined
? undefined
: check("target", await host.validTarget(target), target, {
remediation: "Supply a readable local app or program path.",
classification: "config_drift",
});
const check = (
name: string,
ok: boolean,
detail: string | undefined,
failure: {
readonly remediation: string;
readonly classification: Exclude<DoctorCheck["classification"], "healthy">;
},
): DoctorCheck => ({
name,
ok,
classification: ok ? "healthy" : failure.classification,
...(detail === undefined ? {} : { detail }),
...(ok ? {} : { remediation: failure.remediation }),
});
const providerDoctorChecks = (
inspection: DoctorProviderInspection,
): readonly DoctorCheck[] =>
inspection.checks.map((candidate) => ({
name:
candidate.name === "configuration"
? inspection.id
: `${inspection.id}-${candidate.name}`,
ok: candidate.ok,
classification: candidate.ok ? "healthy" : candidate.classification,
detail: candidate.detail,
...(candidate.remediation === null
? {}
: { remediation: candidate.remediation }),
}));
const unsupportedHopperRemediation = (configured: boolean): string =>
configured
? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA."
: "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build.";
const parseMajor = (version: string | undefined): number =>
Number.parseInt(version?.split(".")[0] ?? "0", 10);
const providerCheckName = (providerId: string, name: string): boolean =>
name === providerId || name.startsWith(`${providerId}-`);

View File

@ -16,6 +16,7 @@ import {
parseProcessCapture,
} from "../domain/processCapture.js";
import { captureProcessScenario } from "./ProcessHarness.js";
import { processCapturePermissionRequest } from "./ProcessCapturePermission.js";
import {
PROCESS_PROVIDER,
createProcessCaptureEvidence,
@ -49,18 +50,7 @@ export const captureProcessScenarioFile = async (path: string) => {
const authority = await loadConfiguredPermissionAuthority(config.value);
if (!authority.ok) return cliAnalysisError(authority.error);
const authorized = await authority.value.authorize(
{
capability: "process_capture",
roots: [scenario.working_directory, ...scenario.filesystem_roots],
executables: [scenario.executable],
environment_names: [
...Object.keys(scenario.environment),
...scenario.inherit_environment,
],
network: scenario.network_access === "host" ? "external" : "none",
mount: false,
operation_identity: `capture_process_scenario:${scenario.executable}`,
},
processCapturePermissionRequest(scenario),
"read",
);
if (!authorized.ok)

View File

@ -596,118 +596,156 @@ export const projectAnalysisError = (
const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => {
if (error instanceof ReplayPlanStaleError) return "plan_stale";
if (error instanceof PermissionRequiredError) return "permission_required";
if (
error instanceof ProviderSelectionError &&
error.reason === "provider_unavailable"
)
return "provider_unavailable";
if (error instanceof BrowserObservationError) {
if (error.reason === "payload_limit") return "truncated";
if (
error.reason === "target_not_found" ||
error.reason === "target_not_allowed" ||
error.reason === "target_changed"
)
return "target_unavailable";
if (
error.reason === "endpoint_unreachable" ||
error.reason === "disconnected"
)
return "provider_unavailable";
return "unreadable_output";
}
if (error instanceof ProviderSelectionError)
return error.reason === "provider_unavailable"
? "provider_unavailable"
: "capability_unavailable";
if (error instanceof BrowserObservationError)
return browserErrorCode(error.reason);
if (error instanceof ArtifactOperationError)
return error.reason === "integrity" && error.artifactDetails !== undefined
? "artifact_integrity_mismatch"
: error.reason === "limit"
? "truncated"
: error.reason === "cancelled"
? "cancelled"
: "artifact_operation_failed";
if (error instanceof EvidenceFileError)
return error.reason === "outside-root"
? "outside_approved_root"
: error.reason === "too-large"
? "truncated"
: error.reason === "disabled"
? "capability_unavailable"
: "execution_failure";
if (error instanceof InvestigationWorkspaceError) {
if (
error.reason === "revision-conflict" ||
error.reason === "name-conflict"
)
return "revision_conflict";
if (error.reason === "too-large") return "truncated";
if (error.reason === "disabled") return "capability_unavailable";
if (error.reason === "outside-root") return "outside_approved_root";
if (error.reason === "integrity" || error.reason === "invalid-json")
return "evidence_integrity_mismatch";
return "execution_failure";
}
return artifactOperationCode(error);
if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason);
if (error instanceof InvestigationWorkspaceError)
return investigationWorkspaceCode(error.reason);
if (error instanceof UnknownRegistryError)
return error.reason === "revision-conflict" ||
error.reason === "already-exists"
? "revision_conflict"
: error.reason === "limit"
? "truncated"
: error.reason === "integrity"
? "evidence_integrity_mismatch"
: "execution_failure";
if (error._tag === "ProcessCaptureError")
return error.cleanupIncomplete
? "cleanup_incomplete"
: error.userCategory === "cancelled"
? "cancelled"
: error.userCategory === "permission_required"
? "permission_required"
: "process_capture_failed";
switch (error._tag) {
case "AnalysisProtocolError":
case "AnalysisOutputError":
case "HopperProtocolError":
return "unreadable_output";
case "AnalysisInputError":
return "invalid_request";
case "AnalysisCapabilityUnavailableError":
case "ProviderSelectionError":
return "capability_unavailable";
case "AnalysisCancelledError":
case "HopperCancelledError":
return "cancelled";
case "AnalysisTimeoutError":
case "HopperTimeoutError":
return "provider_timeout";
case "HopperProcessError":
case "HopperStartError":
return "provider_unavailable";
case "ConfigurationError":
return "configuration_invalid";
case "NoBinaryOpenError":
case "BinaryTargetError":
return "target_unavailable";
case "EvidenceIntegrityError":
return "evidence_integrity_mismatch";
case "EvidenceLimitError":
return "truncated";
case "ProviderAdapterError":
case "HopperRemoteError":
return "execution_failure";
case "BrowserObservationError":
throw new TypeError("Unhandled specialized browser error");
return unknownRegistryCode(error.reason);
if (error._tag === "ProcessCaptureError") return processCaptureCode(error);
return staticErrorCode(error._tag);
};
const browserErrorCode = (
reason: BrowserObservationError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "payload_limit") return "truncated";
if (
reason === "target_not_found" ||
reason === "target_not_allowed" ||
reason === "target_changed"
)
return "target_unavailable";
if (reason === "endpoint_unreachable" || reason === "disconnected")
return "provider_unavailable";
return "unreadable_output";
};
const artifactOperationCode = (
error: ArtifactOperationError,
): AnalysisErrorProjection["code"] => {
if (error.reason === "integrity" && error.artifactDetails !== undefined)
return "artifact_integrity_mismatch";
if (error.reason === "limit") return "truncated";
if (error.reason === "cancelled") return "cancelled";
return "artifact_operation_failed";
};
const evidenceFileCode = (
reason: EvidenceFileError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "outside-root") return "outside_approved_root";
if (reason === "too-large") return "truncated";
if (reason === "disabled") return "capability_unavailable";
return "execution_failure";
};
const investigationWorkspaceCode = (
reason: InvestigationWorkspaceError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "revision-conflict" || reason === "name-conflict")
return "revision_conflict";
if (reason === "too-large") return "truncated";
if (reason === "disabled") return "capability_unavailable";
if (reason === "outside-root") return "outside_approved_root";
if (reason === "integrity" || reason === "invalid-json")
return "evidence_integrity_mismatch";
return "execution_failure";
};
const unknownRegistryCode = (
reason: UnknownRegistryError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "revision-conflict" || reason === "already-exists")
return "revision_conflict";
if (reason === "limit") return "truncated";
if (reason === "integrity") return "evidence_integrity_mismatch";
return "execution_failure";
};
const processCaptureCode = (
error: AnalysisError,
): AnalysisErrorProjection["code"] => {
if (error.cleanupIncomplete) return "cleanup_incomplete";
if (error.userCategory === "cancelled") return "cancelled";
if (error.userCategory === "permission_required")
return "permission_required";
return "process_capture_failed";
};
type SpecializedErrorTag =
| "ArtifactOperationError"
| "BrowserObservationError"
| "EvidenceFileError"
| "InvestigationWorkspaceError"
| "PermissionRequiredError"
| "ProcessCaptureError"
| "ProviderSelectionError"
| "ReplayPlanStaleError"
| "UnknownRegistryError";
const STATIC_ERROR_CODES = {
AnalysisProtocolError: "unreadable_output",
AnalysisOutputError: "unreadable_output",
HopperProtocolError: "unreadable_output",
AnalysisInputError: "invalid_request",
AnalysisCapabilityUnavailableError: "capability_unavailable",
AnalysisCancelledError: "cancelled",
HopperCancelledError: "cancelled",
AnalysisTimeoutError: "provider_timeout",
HopperTimeoutError: "provider_timeout",
HopperProcessError: "provider_unavailable",
HopperStartError: "provider_unavailable",
ConfigurationError: "configuration_invalid",
NoBinaryOpenError: "target_unavailable",
BinaryTargetError: "target_unavailable",
EvidenceIntegrityError: "evidence_integrity_mismatch",
EvidenceLimitError: "truncated",
ProviderAdapterError: "execution_failure",
HopperRemoteError: "execution_failure",
} as const satisfies Readonly<
Record<
Exclude<AnalysisErrorTag, SpecializedErrorTag>,
AnalysisErrorProjection["code"]
>
>;
const staticErrorCode = (
tag: AnalysisErrorTag,
): AnalysisErrorProjection["code"] => {
switch (tag) {
case "ArtifactOperationError":
case "BrowserObservationError":
case "EvidenceFileError":
case "InvestigationWorkspaceError":
case "UnknownRegistryError":
case "PermissionRequiredError":
throw new TypeError("Unhandled specialized analysis error");
case "ProcessCaptureError":
case "ProviderSelectionError":
case "ReplayPlanStaleError":
throw new TypeError("Unhandled specialized replay error");
case "UnknownRegistryError":
throw new TypeError(`Unhandled specialized analysis error: ${tag}`);
default:
return STATIC_ERROR_CODES[tag];
}
};
const errorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined =>
requestErrorDetails(error) ??
artifactStateErrorDetails(error) ??
providerErrorDetails(error) ??
lifecycleErrorDetails(error);
const requestErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof AnalysisInputError && error.issues.length > 0)
return {
@ -740,6 +778,12 @@ const errorDetails = (
missing: missingScopeDetails(error.missing),
ceiling: error.ceiling === null ? null : scopeDetails(error.ceiling),
};
return undefined;
};
const artifactStateErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof ArtifactOperationError && error.artifactDetails)
return {
logical_path: error.artifactDetails.logicalPath,
@ -753,14 +797,6 @@ const errorDetails = (
reason: error.reason,
...(error.reason === "limit" ? { truncated: true } : {}),
};
if (error instanceof AnalysisCancelledError)
return { operation: error.operation, cleanup: "complete" };
if (error instanceof HopperCancelledError)
return { operation: "hopper", cleanup: "complete" };
if (error instanceof AnalysisTimeoutError)
return { operation: error.operation, timeout_ms: error.timeoutMs };
if (error instanceof HopperTimeoutError)
return { operation: "hopper", timeout_ms: error.timeoutMs };
if (error instanceof EvidenceLimitError)
return { limit: error.limit, maximum: error.maximum, truncated: true };
if (error instanceof InvestigationWorkspaceError)
@ -768,6 +804,12 @@ const errorDetails = (
if (error instanceof UnknownRegistryError) return { reason: error.reason };
if (error instanceof EvidenceFileError)
return { operation: error.operation, reason: error.reason };
return undefined;
};
const providerErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof AnalysisCapabilityUnavailableError)
return { provider_id: error.providerId, operation: error.operation };
if (error instanceof ProviderSelectionError)
@ -797,6 +839,20 @@ const errorDetails = (
return { provider_code: error.code, diagnostic_type: error.diagnosticType };
if (error instanceof HopperProcessError && error.failureCode !== undefined)
return { failure_code: error.failureCode, exit_code: error.exitCode };
return undefined;
};
const lifecycleErrorDetails = (
error: AnalysisError,
): Readonly<Record<string, JsonValue>> | undefined => {
if (error instanceof AnalysisCancelledError)
return { operation: error.operation, cleanup: "complete" };
if (error instanceof HopperCancelledError)
return { operation: "hopper", cleanup: "complete" };
if (error instanceof AnalysisTimeoutError)
return { operation: error.operation, timeout_ms: error.timeoutMs };
if (error instanceof HopperTimeoutError)
return { operation: "hopper", timeout_ms: error.timeoutMs };
if (error._tag === "ProcessCaptureError" && error.cleanupIncomplete)
return {
cleanup: "incomplete",
@ -870,17 +926,8 @@ const errorCategory = (
return "unavailable";
if (error._tag === "ProcessCaptureError")
return error.userCategory ?? "execution_failure";
if (error instanceof BrowserObservationError) {
if (error.reason === "payload_limit") return "truncated";
if (
error.reason === "target_not_found" ||
error.reason === "target_not_allowed" ||
error.reason === "target_changed" ||
error.reason === "endpoint_unreachable" ||
error.reason === "disconnected"
)
return "unavailable";
}
if (error instanceof BrowserObservationError)
return browserErrorCategory(error.reason);
if (
error instanceof HopperRemoteError &&
error.diagnosticType === "authorization"
@ -895,6 +942,21 @@ const errorCategory = (
return STATIC_ERROR_CATEGORIES[error._tag] ?? "execution_failure";
};
const browserErrorCategory = (
reason: BrowserObservationError["reason"],
): AnalysisErrorProjection["category"] => {
if (reason === "payload_limit") return "truncated";
if (
reason === "target_not_found" ||
reason === "target_not_allowed" ||
reason === "target_changed" ||
reason === "endpoint_unreachable" ||
reason === "disconnected"
)
return "unavailable";
return "execution_failure";
};
const investigationWorkspaceCategory = (
reason: InvestigationWorkspaceError["reason"],
): AnalysisErrorProjection["category"] => {
@ -944,18 +1006,8 @@ const userMessage = (error: AnalysisError): string => {
if (error instanceof AnalysisInputError)
return "Analysis input is invalid. Check the arguments and try again.";
if (error.userMessage !== undefined) return error.userMessage;
if (UNREADABLE_OUTPUT_TAGS.has(error._tag))
return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.";
if (UNSUPPORTED_PROVIDER_TAGS.has(error._tag))
return "This analysis is unavailable for the current target. Choose another analysis or target.";
if (CANCELLED_TAGS.has(error._tag))
return "Analysis was cancelled. Start it again when ready.";
if (TIMEOUT_TAGS.has(error._tag))
return "Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.";
if (ADAPTER_FAILURE_TAGS.has(error._tag))
return "Analysis could not complete. Retry once; if it continues, run `rea doctor`.";
if (START_FAILURE_TAGS.has(error._tag))
return "Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.";
const standardMessage = standardErrorMessage(error._tag);
if (standardMessage !== undefined) return standardMessage;
if (error instanceof ArtifactOperationError)
return artifactMessage(error.reason);
if (error instanceof EvidenceIntegrityError)
@ -981,6 +1033,22 @@ const userMessage = (error: AnalysisError): string => {
return "Analysis could not complete. Run `rea doctor`, then try again.";
};
const standardErrorMessage = (tag: AnalysisErrorTag): string | undefined => {
if (UNREADABLE_OUTPUT_TAGS.has(tag))
return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.";
if (UNSUPPORTED_PROVIDER_TAGS.has(tag))
return "This analysis is unavailable for the current target. Choose another analysis or target.";
if (CANCELLED_TAGS.has(tag))
return "Analysis was cancelled. Start it again when ready.";
if (TIMEOUT_TAGS.has(tag))
return "Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.";
if (ADAPTER_FAILURE_TAGS.has(tag))
return "Analysis could not complete. Retry once; if it continues, run `rea doctor`.";
if (START_FAILURE_TAGS.has(tag))
return "Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.";
return undefined;
};
const UNREADABLE_OUTPUT_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"AnalysisProtocolError",
"AnalysisOutputError",

View File

@ -41,16 +41,19 @@ export class NativeCommandFailure extends Error {
}
}
/** Bounds and cancellation applied to one allowlisted native command. */
export interface NativeCommandOptions {
readonly signal?: AbortSignal;
readonly timeoutMs: number;
readonly maxOutputBytes: number;
readonly acceptNonZero?: boolean;
}
export interface NativeCommandRunner {
run(
tool: string,
arguments_: readonly string[],
options: {
readonly signal?: AbortSignal;
readonly timeoutMs: number;
readonly maxOutputBytes: number;
readonly acceptNonZero?: boolean;
},
options: NativeCommandOptions,
): Promise<Result<NativeCommandCapture, NativeCommandFailure>>;
}
@ -74,12 +77,7 @@ export class XcrunCommandRunner implements NativeCommandRunner {
run(
tool: string,
arguments_: readonly string[],
options: {
readonly signal?: AbortSignal;
readonly timeoutMs: number;
readonly maxOutputBytes: number;
readonly acceptNonZero?: boolean;
},
options: NativeCommandOptions,
): Promise<Result<NativeCommandCapture, NativeCommandFailure>> {
if (!ALLOWED_TOOLS.has(tool))
return Promise.resolve(