import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { STARTUP_OWNER_CLOSED } from "../process/SharedStartupAttempt.js"; import { constants } from "node:fs"; import { access, readdir, stat } from "node:fs/promises"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import type { RetainedResourceScope } from "./AnalysisProvider.js"; import { nativeAnalysisConfigurationError } from "../config/parseConfig.js"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import type { ConfigurationSettingIssue } from "../domain/configurationErrors.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { join } from "node:path"; import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import { parseBinaryTarget } from "./BinaryTargetResolver.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { javaScriptRuntime, type JavaScriptRuntime, } from "../domain/runtimeVersion.js"; import { installedCaskAppdir, probeHomebrew } from "./homebrew.js"; import { macHopperApplicationRoots, macHopperRenamedLauncherCandidates, } from "./MacHopperPaths.js"; import { inspectLinuxHopperLauncher, linuxSharedLibrariesAvailable, readLinuxDistribution, type LinuxDistribution, } from "./LinuxHopper.js"; import type { Result } from "../domain/result.js"; import type { HopperReleaseError } from "./HopperReleaseFeed.js"; import type { VerifiedLinuxHopperLauncher } from "./LinuxHopperPublisher.js"; import { readInstalledSkillIdentity, type InstalledSkillIdentity, type SkillPathIssue, } from "./SetupSkill.js"; import { DEFAULT_CATALOG_IDENTITY } from "../catalogIdentity.js"; import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js"; import { readClientRegistrationStatuses, type ClientRegistrationStatus, type UnhealthyClientRegistrationStatus, } from "./ClientRegistrationStatus.js"; import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js"; import { collectDoctorDiagnostics, doctorHealthy, } from "./DoctorDiagnostics.js"; import { normalizeDoctorScope, scopeDoctorChecks, type DoctorScopeReport, } from "./DoctorScope.js"; /** One actionable environment diagnostic. */ export interface DoctorCheck { readonly name: string; readonly ok: boolean; readonly classification: | "healthy" | "missing_dependency" | "runtime_failure" | "unsupported_host" | "unsupported_target" | "missing_analysis_engine" | "unknown" | "config_drift"; readonly detail?: string; readonly details?: Readonly>; readonly remediation?: string; } /** One provider-owned check projected without importing its adapter types. */ export interface DoctorProviderCheck { readonly name: string; readonly ok: boolean; readonly code: string | null; readonly detail: string; readonly remediation: string | null; readonly classification: Exclude; readonly details?: Readonly>; } /** Read-only provider inspection supplied by an outer composition adapter. */ export interface DoctorProviderInspection { readonly id: string; readonly configured: boolean; readonly available: boolean; readonly providerVersion: string | null; /** Adapter-validated non-secret settings, independent of runtime readiness. * Invalid installations publish no settings. */ readonly registrationEnvironment: Readonly>; readonly checks: readonly DoctorProviderCheck[]; } /** Read-only host capabilities required by diagnostics. */ export interface DoctorHost { readonly signal?: AbortSignal; readonly platform: NodeJS.Platform; readonly architecture: NodeJS.Architecture; readonly runtime: JavaScriptRuntime; readonly homeDirectory: string; readonly configuredHopperPath?: string; readonly configuredHopperStartupTimeout?: string; /** * Settings `parseConfig` rejects before native analysis starts. * Undefined means this host did not run that admission. */ readonly configurationIssues?: readonly ConfigurationSettingIssue[]; readonly configuredIlspyCmdPath?: string; macosVersion(): Promise; linuxDistribution(): Promise; validTarget(path: string): Promise; /** Route-specific target admission details, when supplied by the host. */ inspectTarget?(path: string): Promise; executable(path: string): Promise; inspectLinuxHopper( path: string, ): Promise>; linuxDemoRuntimeCheck(): Promise; brewHopperPath(): Promise; manualHopperPaths(): Promise; providerInspections?(): Promise; /** Resolve a parsed upstream request budget from the effective client environment. */ resolveProviderRequestBudget?: ProviderRequestBudgetResolver; /** Validated provider settings preserved by setup, independent of readiness. */ providerRegistrationEnvironment?(): Readonly>; installationPaths?(): Promise; /** Undefined checks installed skill roots; explicit selections stay scoped. */ installedSkillIdentity?( clientIds: readonly string[] | undefined, ): Promise; close?(): Promise; clientRegistrations?(): Promise; ilspyCmdVersion?(path: string): Promise; } /** Structured result returned by the read-only doctor workflow. */ export interface DoctorReport { readonly healthy: boolean; readonly environment_healthy: boolean; readonly scope: DoctorScopeReport; readonly scope_checks: readonly DoctorCheck[]; readonly informational_checks: readonly DoctorCheck[]; readonly hopperPath?: string; readonly providerInspections?: readonly DoctorProviderInspection[]; readonly checks: readonly DoctorCheck[]; readonly identity?: DoctorIdentity; } /** Caller-selected readiness boundary; omitted values retain audit semantics. */ export interface DoctorScope { readonly clients?: readonly string[]; readonly skillClients?: readonly string[]; readonly providers?: readonly string[]; readonly skill?: boolean; } /** Installed-package, skill, client, and runtime identity observations. */ export type DoctorSkillState = | "aligned" | "stale" | "missing" | "unmanaged" | "unknown"; interface DoctorIdentity { readonly cli_package_version: string; readonly expected_skill_version: string; readonly sdk: typeof SDK_IDENTITY; /** Package default advertisement; the active server reports its selected profile. */ readonly catalog: typeof DEFAULT_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 state: DoctorSkillState; readonly issues?: readonly SkillPathIssue[]; readonly remediation: string | null; }; readonly registrations: readonly ClientRegistrationStatus[]; } /** * Check requirements and an optional target without mutating the host. * Every failed check includes remediation suitable for either the one-shot CLI * or an agent consuming the structured result. */ export const runDoctor = async ( target?: string, selectedHost?: DoctorHost, requestedScope?: DoctorScope, ): Promise => selectedHost === undefined ? runDoctorOwned(target, systemDoctorHost(), requestedScope) : inspectDoctor(target, selectedHost, requestedScope); /** Inspect with a host whose lifetime belongs to this operation. */ export const runDoctorOwned = async ( target: string | undefined, host: DoctorHost, requestedScope?: DoctorScope, ): Promise => { let report: DoctorReport; try { report = await inspectDoctor(target, host, requestedScope); } catch (cause: unknown) { const cleanup = await closeDoctorHost(host); if (cleanup !== undefined && cause instanceof AnalysisError) { const cleanupError = typeof cleanup === "string" ? new ProviderCleanupError( "rea", [], { reason: cleanup }, { operation: "doctor" }, ) : cleanup; throw analysisErrorWithCleanupFailure( cause, cleanupError, "doctor", ).bindCleanupDependencies([cause, cleanupError]); } if (cleanup !== undefined) throw new AggregateError( [cause, typeof cleanup === "string" ? new Error(cleanup) : cleanup], "Doctor failed and its resource cleanup remains incomplete.", { cause }, ); throw cause; } const cleanup = await closeDoctorHost(host); if (cleanup === undefined && report.healthy && host.signal?.aborted) throw new AnalysisCancelledError("doctor", { cause: host.signal.reason, diagnostics: { doctor_report: jsonValueSchema.parse(report) }, }); return cleanup === undefined ? report : doctorReportWithCleanup(report, cleanup); }; const inspectDoctor = async ( target: string | undefined, host: DoctorHost, requestedScope?: DoctorScope, ): Promise => { if (host.signal?.aborted) throw new AnalysisCancelledError("doctor", { cause: host.signal.reason }); const scope = normalizeDoctorScope(requestedScope, target); const { checks: diagnosticChecks, hopperPath, providerInspections, } = await collectDoctorDiagnostics(target, host); const identity = await collectDoctorIdentity(host, scope); const checks = [ ...diagnosticChecks, ...identity.checks, ...registrationAdvisoryChecks(identity.value.registrations), ]; const providers = { hopperAvailable: hopperPath !== undefined && checks .filter(({ name }) => name.startsWith("hopper")) .every(({ ok }) => ok), hopperConfigured: host.configuredHopperPath !== undefined, providerInspections, }; const environmentHealthy = doctorHealthy(checks, providers); const scoped = scopeDoctorChecks({ checks, registrations: identity.value.registrations, skillState: identity.value.skill.state, providers, scope, }); return { healthy: scope.mode === "audit-wide" ? environmentHealthy : scoped.healthy, environment_healthy: environmentHealthy, scope, scope_checks: scope.mode === "audit-wide" ? checks : scoped.scopeChecks, informational_checks: scope.mode === "audit-wide" ? [] : scoped.informationalChecks, ...(hopperPath === undefined ? {} : { hopperPath }), ...(providerInspections === undefined ? {} : { providerInspections }), checks, identity: identity.value, }; }; const registrationAdvisoryChecks = ( registrations: readonly ClientRegistrationStatus[], ): readonly DoctorCheck[] => registrations.flatMap(({ client, advisory }) => advisory === undefined ? [] : [ { name: `registration_timeout:${client}`, ok: true, classification: "healthy" as const, detail: advisory, }, ], ); const closeDoctorHost = async ( host: DoctorHost, ): Promise => { try { return await host.close?.(); } catch (cause: unknown) { if (cause instanceof AnalysisError) return cause; return `Doctor host cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`; } }; const doctorReportWithCleanup = ( report: DoctorReport, cleanup: string | AnalysisError, ): DoctorReport => { const check: DoctorCheck = { name: "doctor-resource-cleanup", ok: false, classification: "config_drift", detail: typeof cleanup === "string" ? cleanup : `${cleanup.message} ${cleanup.cleanupResources.join(", ")}`, ...(typeof cleanup === "string" ? {} : { details: { cleanup_failure: projectAnalysisError(cleanup) } }), remediation: "Resolve the reported cleanup failure, then rerun rea doctor.", }; return { ...report, healthy: false, environment_healthy: false, checks: [...report.checks, check], scope_checks: [...report.scope_checks, check], }; }; const collectDoctorIdentity = async ( host: DoctorHost, scope: DoctorScopeReport, ): Promise<{ readonly checks: readonly DoctorCheck[]; readonly value: DoctorIdentity; }> => { const installationPaths = (await host.installationPaths?.()) ?? []; const registrations = (await host.clientRegistrations?.()) ?? []; const skillClientIds = scope.skill_clients ?? (scope.clients.length > 0 ? scope.clients : undefined); const installedSkillIdentity = await host.installedSkillIdentity?.(skillClientIds); const skillAligned = skillIdentityAligned(installedSkillIdentity); return { checks: [ ...(skillAligned ? [] : [skillIdentityCheck(installedSkillIdentity)]), ...registrations .filter( (registration): registration is UnhealthyClientRegistrationStatus => registration.state !== "aligned" && registration.state !== "manual", ) .map(registrationCheck), ], value: { cli_package_version: PRODUCT_IDENTITY.packageVersion, expected_skill_version: PRODUCT_IDENTITY.skillVersion, sdk: SDK_IDENTITY, catalog: { counts: DEFAULT_CATALOG_IDENTITY.counts, digests: DEFAULT_CATALOG_IDENTITY.digests, }, live_server: { state: "unknown", remediation: "Call binary_session in the active client to inspect the running server identity.", }, installations: { paths: installationPaths, state: installationState(installationPaths), }, skill: { installed_version: installedSkillIdentity?.version ?? null, installed_tool_count: installedSkillIdentity?.toolCount ?? null, state: skillIdentityState(installedSkillIdentity), ...(installedSkillIdentity?.issues === undefined ? {} : { issues: installedSkillIdentity.issues }), remediation: skillAligned ? null : skillIdentityRemediation(installedSkillIdentity), }, registrations, }, }; }; const skillIdentityAligned = ( identity: InstalledSkillIdentity | undefined, ): boolean => identity?.version === PRODUCT_IDENTITY.skillVersion && identity.toolCount === DEFAULT_CATALOG_IDENTITY.counts.mcp_tools && identity.canonical; const skillIdentityState = ( identity: InstalledSkillIdentity | undefined, ): DoctorSkillState => { if (identity?.issues?.some(({ kind }) => kind === "unreadable")) return "unknown"; if ((identity?.issues?.length ?? 0) > 0) return "unmanaged"; return identity === undefined ? "missing" : skillIdentityAligned(identity) ? "aligned" : "stale"; }; const skillIdentityRemediation = ( identity: InstalledSkillIdentity | undefined, ): string => { const issues = identity?.issues; if (issues !== undefined && issues.length > 0) return `${issues.map(({ path, detail }) => `${detail} (${path})`).join(" ")} Verify the reported paths and use regular skill directories, or run rea setup with --skill=false to configure the client without installing the skill.`; return "Run rea setup to update the installed REA skill."; }; const skillIdentityCheck = ( identity: InstalledSkillIdentity | undefined, ): DoctorCheck => { const issues = identity?.issues; return { name: "skill:identity", ok: false, classification: "config_drift", detail: issues !== undefined && issues.length > 0 ? `Selected skill identity is ${skillIdentityState(identity)}: ${issues.map(({ path }) => path).join(", ")}.` : identity === undefined ? "Installed REA skill identity is missing." : "Installed REA skill identity is stale.", remediation: skillIdentityRemediation(identity), }; }; const registrationCheck = ( registration: UnhealthyClientRegistrationStatus, ): DoctorCheck => ({ name: `registration:${registration.client}`, ok: false, classification: registration.state === "unknown" ? "unknown" : "config_drift", detail: registration.config_path, remediation: registration.remediation, }); 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 { readonly signal?: AbortSignal; readonly platform?: NodeJS.Platform; readonly architecture?: NodeJS.Architecture; readonly environment?: NodeJS.ProcessEnv; readonly execFileOutput?: typeof execFileOutput; readonly providerInspections?: ( resourceScope: RetainedResourceScope, signal: AbortSignal, ) => Promise; readonly providerRegistrationEnvironment?: () => Readonly< Record >; readonly resolveProviderRequestBudget?: ( provider: Parameters[0], environment: Parameters[1], resources: RetainedResourceScope, signal: AbortSignal, ) => ReturnType; readonly linuxDemoRuntimeCheck?: () => Promise; } /** Create diagnostics backed by the current process and host commands. */ export const systemDoctorHost = ( options: SystemDoctorHostOptions = {}, ): DoctorHost => { const resources = new ArtifactResourceScope(); const controller = new AbortController(); const inspectionSignal = options.signal === undefined ? controller.signal : AbortSignal.any([options.signal, controller.signal]); const platform = options.platform ?? process.platform; const architecture = options.architecture ?? process.arch; const environment = options.environment ?? process.env; const configurationError = nativeAnalysisConfigurationError( snapshotEnvironment(environment, platform), ); const hostExecFileOutput = options.execFileOutput ?? execFileOutput; const homeDirectory = homeDirectoryFromEnvironment(environment, platform); const commandEnvironment = { env: environment }; const resourceScope: RetainedResourceScope = { retain: (owner, resource) => resources.retain({ kind: "lease", resource, lease: AnalysisCleanupLease.forCloseOwner(owner, resource), }), run: (operation) => resources.run(operation), }; const resolveProviderRequestBudget = options.resolveProviderRequestBudget === undefined ? undefined : ( provider: Parameters[0], environment: Parameters[1], ) => options.resolveProviderRequestBudget!( provider, environment, resourceScope, inspectionSignal, ); return { ...(options.signal === undefined ? {} : { signal: options.signal }), platform, architecture, runtime: javaScriptRuntime(process.versions), homeDirectory, ...(environment.HOPPER_LAUNCHER_PATH === undefined ? {} : { configuredHopperPath: environment.HOPPER_LAUNCHER_PATH }), ...(environment.REA_HOPPER_STARTUP_TIMEOUT_MS === undefined ? {} : { configuredHopperStartupTimeout: environment.REA_HOPPER_STARTUP_TIMEOUT_MS, }), configurationIssues: configurationError?.settings ?? [], ...(environment.REA_ILSPY_CMD_PATH === undefined ? {} : { configuredIlspyCmdPath: environment.REA_ILSPY_CMD_PATH }), macosVersion: () => readMacosVersion(hostExecFileOutput, commandEnvironment), linuxDistribution: readLinuxDistribution, async validTarget(path) { return ( await parseBinaryTarget(path, { cwd: process.cwd(), hostArchitecture: architecture, }) ).ok; }, async inspectTarget(path) { const result = await parseBinaryTarget(path, { cwd: process.cwd(), hostArchitecture: architecture, }); if (result.ok) return { name: "target", ok: true, classification: "healthy", detail: path, }; const error = result.error; const targetError = error instanceof BinaryTargetError ? error : undefined; return { name: "target", ok: false, classification: targetError?.constraint !== undefined ? "unsupported_target" : "config_drift", detail: path, details: { reason: targetError?.reason ?? error.message, ...(targetError?.constraint === undefined ? {} : { constraint: targetError.constraint }), }, remediation: analysisErrorRemediationAction(error), }; }, executable: (path) => executableAvailable( path, platform, hostExecFileOutput, commandEnvironment, ), linuxDemoRuntimeCheck: options.linuxDemoRuntimeCheck ?? uncomposedLinuxDemoRuntimeCheck, inspectLinuxHopper: (path) => inspectLinuxHopperLauncher(path, environment), async brewHopperPath() { return probeHomebrew(async (command) => { let caskroom: string; try { // Without a cask argument, `--caskroom` does not load Homebrew's // cask API, so doctor writes no Homebrew cache files. caskroom = ( await hostExecFileOutput( command, ["--caskroom"], commandEnvironment, ) ).stdout.trim(); } catch (cause: unknown) { // best-effort cleanup: optional Homebrew probing; absence means uninstalled. void cause; return undefined; } const appdir = await installedCaskAppdir( caskroom, "hopper-disassembler", homeDirectory, ); return appdir === undefined ? undefined : join(appdir, "Hopper Disassembler.app/Contents/MacOS/hopper"); }); }, manualHopperPaths: () => manualHopperPaths(homeDirectory), ...(options.providerInspections === undefined ? {} : { providerInspections: () => resources.run(() => options.providerInspections!(resourceScope, inspectionSignal), ), }), ...(options.providerRegistrationEnvironment === undefined ? {} : { providerRegistrationEnvironment: options.providerRegistrationEnvironment, }), ...(resolveProviderRequestBudget === undefined ? {} : { resolveProviderRequestBudget }), async ilspyCmdVersion(path) { try { await access(path, constants.X_OK); return firstIlspyVersionLine( ( await hostExecFileOutput(path, ["--version"], { timeout: 10_000, ...commandEnvironment, }) ).stdout, ); } catch (cause: unknown) { // best-effort cleanup: optional version probing; failure means unavailable. void cause; return undefined; } }, async installationPaths() { try { const command = platform === "win32" ? "where" : "which"; const arguments_ = platform === "win32" ? ["rea"] : ["-a", "rea"]; return uniqueLines( (await hostExecFileOutput(command, arguments_, commandEnvironment)) .stdout, ); } catch (cause: unknown) { // best-effort cleanup: optional path probing; failure means none found. void cause; return []; } }, installedSkillIdentity: (clientIds) => readInstalledSkillIdentity( homeDirectory, clientIds, environment, platform, resources, ), close: async () => { controller.abort(STARTUP_OWNER_CLOSED); try { await resources.close(); return undefined; } catch (cause: unknown) { const observation = ArtifactReaderFailure.cleanupObservation( cause, "doctor resources", ); const failure = new ProviderCleanupError( "rea", observation.resources, { reason: observation.reason }, { cause, operation: "doctor" }, ); bindArtifactFileCleanup(cause, failure); throw failure; } }, clientRegistrations: () => readClientRegistrationStatuses(homeDirectory, undefined, { platform, environment, resources, ...(resolveProviderRequestBudget === undefined ? {} : { resolveProviderRequestBudget, }), }), }; }; const readMacosVersion = async ( run: typeof execFileOutput, options: { readonly env: NodeJS.ProcessEnv }, ): Promise => { try { return (await run("sw_vers", ["-productVersion"], options)).stdout.trim(); } catch (cause: unknown) { // best-effort cleanup: optional OS version probing; failure means unknown. void cause; return undefined; } }; const executableAvailable = async ( path: string, platform: NodeJS.Platform, run: typeof execFileOutput, options: { readonly env: NodeJS.ProcessEnv }, ): Promise => { try { await access(path, constants.X_OK); if (!(await stat(path)).isFile()) return false; if (platform !== "linux") return true; const linked = await run("ldd", [path], options); return linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`); } catch (cause: unknown) { // best-effort cleanup: optional executable probing; failure means unavailable. void cause; return false; } }; const uncomposedLinuxDemoRuntimeCheck = (): Promise => Promise.resolve({ name: "hopper-demo-runtime", ok: false, classification: "config_drift", detail: "Linux Hopper private-display diagnostics were not composed", remediation: "Run rea doctor through the production CLI adapter.", }); const manualHopperPaths = async (home: string): Promise => { const roots = macHopperApplicationRoots(home); const bundleNamesByRoot = new Map(); for (const root of roots) { try { bundleNamesByRoot.set( root, (await readdir(root, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) .map(({ name }) => name), ); } catch (cause: unknown) { // A missing or unreadable optional application directory is not fatal. void cause; } } return macHopperRenamedLauncherCandidates(home, bundleNamesByRoot); }; const uniqueLines = (value: string): string[] => [ ...new Set( value .split(/\r?\n/u) .map((line) => line.trim()) .filter((line) => line.length > 0), ), ]; const firstIlspyVersionLine = (value: string): string | undefined => uniqueLines(value).find((line) => line.startsWith("ilspycmd:"));