refactor: simplify doctor and error projections
This commit is contained in:
parent
4851449f71
commit
37f4ac6d70
|
|
@ -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}-`);
|
||||
|
|
|
|||
|
|
@ -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}-`);
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue