mirror of
https://github.com/morluto/rea.git
synced 2026-11-04 22:06:10 +00:00
rea doctor reported a skill copy with an unreadable or unresolved path as identity state unknown but classified its skill:identity check as config_drift, which claims the copy has drifted. docs/installation.md keeps an unreadable skill identity unknown, and the sibling registration checks already classify unknown state as unknown. The skill check and the DoctorScope fallback now do the same.
236 lines
7.8 KiB
TypeScript
236 lines
7.8 KiB
TypeScript
import { uniqueSorted } from "../domain/canonicalOrdering.js";
|
|
import { blocksProcessStart } from "../config/processStart.js";
|
|
import type {
|
|
ClientRegistrationStatus,
|
|
UnhealthyClientRegistrationStatus,
|
|
} from "./ClientRegistrationStatus.js";
|
|
import { manualRegistrationRemediation } from "./SupportedClients.js";
|
|
import type {
|
|
DoctorCheck,
|
|
DoctorProviderInspection,
|
|
DoctorScope,
|
|
DoctorSkillState,
|
|
} from "./Doctor.js";
|
|
|
|
/** Normalized readiness boundary included in every doctor response. */
|
|
export interface DoctorScopeReport {
|
|
readonly mode: "audit-wide" | "explicit";
|
|
readonly clients: readonly string[];
|
|
readonly skill_clients?: readonly string[];
|
|
readonly providers: readonly string[];
|
|
readonly skill: boolean;
|
|
readonly target: string | null;
|
|
}
|
|
|
|
interface DoctorProviderState {
|
|
readonly hopperAvailable: boolean;
|
|
readonly hopperConfigured: boolean;
|
|
readonly providerInspections: readonly DoctorProviderInspection[] | undefined;
|
|
}
|
|
|
|
interface DoctorScopeInput {
|
|
readonly checks: readonly DoctorCheck[];
|
|
readonly registrations: readonly ClientRegistrationStatus[];
|
|
readonly skillState: DoctorSkillState;
|
|
readonly providers: DoctorProviderState;
|
|
readonly scope: DoctorScopeReport;
|
|
}
|
|
|
|
/** Normalize a caller-selected doctor readiness boundary. */
|
|
export const normalizeDoctorScope = (
|
|
requested: DoctorScope | undefined,
|
|
target: string | undefined,
|
|
): DoctorScopeReport => ({
|
|
mode: requested === undefined ? "audit-wide" : "explicit",
|
|
clients: uniqueSorted(requested?.clients ?? []),
|
|
...(requested?.skillClients === undefined
|
|
? {}
|
|
: { skill_clients: uniqueSorted(requested.skillClients) }),
|
|
providers: uniqueSorted(requested?.providers ?? []),
|
|
skill: requested?.skill === true,
|
|
target: target ?? null,
|
|
});
|
|
|
|
/** Partition diagnostics into required and informational scope checks. */
|
|
export const scopeDoctorChecks = (
|
|
input: DoctorScopeInput,
|
|
): {
|
|
readonly healthy: boolean;
|
|
readonly scopeChecks: readonly DoctorCheck[];
|
|
readonly informationalChecks: readonly DoctorCheck[];
|
|
} => {
|
|
const providerChecks = input.checks.filter((candidate) =>
|
|
isProviderCheck(candidate.name, input.providers.providerInspections),
|
|
);
|
|
const coreChecks = input.checks.filter(
|
|
({ name }) =>
|
|
name === "node" || (input.scope.target !== null && name === "target"),
|
|
);
|
|
const clientChecks = input.scope.clients.map((client) =>
|
|
selectedRegistrationCheck(
|
|
client,
|
|
input.registrations.find((candidate) => candidate.client === client),
|
|
),
|
|
);
|
|
const skillChecks = input.scope.skill
|
|
? [selectedSkillCheck(input.skillState, input.checks)]
|
|
: [];
|
|
const selectedProviderChecks = input.scope.providers.flatMap((providerId) =>
|
|
selectedProviderChecksFor(providerId, providerChecks, input.providers),
|
|
);
|
|
const hopperHostChecks = input.scope.providers.includes("hopper")
|
|
? input.checks.filter(({ name }) => name === "host")
|
|
: [];
|
|
// parseConfig rejects these before the MCP server starts. Hopper's own
|
|
// scope already includes hopper-startup-timeout through its provider checks.
|
|
const blocksMcpStartup = (check: DoctorCheck): boolean =>
|
|
check.name.startsWith("runtime-configuration:") ||
|
|
check.name === "hopper-startup-timeout";
|
|
const blocksProcess = (check: DoctorCheck): boolean => {
|
|
const setting = check.details?.setting;
|
|
return (
|
|
check.name.startsWith("runtime-configuration:") &&
|
|
typeof setting === "string" &&
|
|
blocksProcessStart(setting)
|
|
);
|
|
};
|
|
const exercisesMcpStartup =
|
|
input.scope.clients.length > 0 || input.scope.providers.length > 0;
|
|
const startupChecks = (
|
|
exercisesMcpStartup
|
|
? input.checks.filter(blocksMcpStartup)
|
|
: input.checks.filter(blocksProcess)
|
|
).filter(
|
|
(check) =>
|
|
check.name !== "hopper-startup-timeout" ||
|
|
!input.scope.providers.includes("hopper"),
|
|
);
|
|
const scopeChecks = [
|
|
...coreChecks,
|
|
...startupChecks,
|
|
...hopperHostChecks,
|
|
...selectedProviderChecks,
|
|
...clientChecks,
|
|
...skillChecks,
|
|
];
|
|
const selectedNames = new Set(scopeChecks.map(({ name }) => name));
|
|
return {
|
|
healthy:
|
|
coreChecks.every(({ ok }) => ok) &&
|
|
startupChecks.every(({ ok }) => ok) &&
|
|
hopperHostChecks.every(({ ok }) => ok) &&
|
|
clientChecks.every(({ ok }) => ok) &&
|
|
skillChecks.every(({ ok }) => ok) &&
|
|
selectedProviderChecks.every(({ ok }) => ok),
|
|
scopeChecks,
|
|
informationalChecks: input.checks.filter(
|
|
({ name }) => !selectedNames.has(name),
|
|
),
|
|
};
|
|
};
|
|
|
|
const isProviderCheck = (
|
|
name: string,
|
|
inspections: readonly DoctorProviderInspection[] | undefined,
|
|
): boolean =>
|
|
name.startsWith("hopper") ||
|
|
inspections?.some(({ id }) => doctorProviderCheckName(id, name)) === true;
|
|
|
|
const selectedProviderChecksFor = (
|
|
providerId: string,
|
|
checks: readonly DoctorCheck[],
|
|
providers: DoctorProviderState,
|
|
): readonly DoctorCheck[] => {
|
|
const selected = checks.filter(({ name }) =>
|
|
providerId === "hopper"
|
|
? name.startsWith("hopper")
|
|
: doctorProviderCheckName(providerId, name),
|
|
);
|
|
const available =
|
|
providerId === "hopper"
|
|
? providers.hopperAvailable
|
|
: providers.providerInspections?.find(({ id }) => id === providerId)
|
|
?.available === true;
|
|
if (available && selected.every(({ ok }) => ok)) return selected;
|
|
if (selected.some(({ ok }) => !ok)) return selected;
|
|
return [
|
|
...selected,
|
|
{
|
|
name: `provider:${providerId}`,
|
|
ok: false,
|
|
classification: "missing_analysis_engine",
|
|
detail: `Requested provider ${providerId} is unavailable.`,
|
|
remediation: `Configure the ${providerId} provider, then rerun rea doctor.`,
|
|
},
|
|
];
|
|
};
|
|
|
|
const selectedRegistrationCheck = (
|
|
client: string,
|
|
status: ClientRegistrationStatus | undefined,
|
|
): DoctorCheck => {
|
|
const manual = manualRegistrationRemediation(client);
|
|
if (status === undefined && manual !== undefined)
|
|
return {
|
|
name: `registration:${client}`,
|
|
ok: false,
|
|
classification: "config_drift",
|
|
detail: `${client} is not registered through a local configuration file.`,
|
|
remediation: manual,
|
|
};
|
|
return status === undefined
|
|
? {
|
|
name: `registration:${client}`,
|
|
ok: false,
|
|
classification: "config_drift",
|
|
detail: `Requested client ${client} was not detected.`,
|
|
remediation: `Install or select ${client}, then run rea setup --client ${client}.`,
|
|
}
|
|
: status.state === "aligned"
|
|
? {
|
|
name: `registration:${client}`,
|
|
ok: true,
|
|
classification: "healthy",
|
|
detail: status.config_path,
|
|
}
|
|
: registrationCheck(status);
|
|
};
|
|
|
|
const registrationCheck = (
|
|
status: UnhealthyClientRegistrationStatus,
|
|
): DoctorCheck => ({
|
|
name: `registration:${status.client}`,
|
|
ok: false,
|
|
classification: status.state === "unknown" ? "unknown" : "config_drift",
|
|
detail: status.config_path,
|
|
remediation: status.remediation,
|
|
});
|
|
|
|
const selectedSkillCheck = (
|
|
state: DoctorScopeInput["skillState"],
|
|
checks: readonly DoctorCheck[],
|
|
): DoctorCheck =>
|
|
checks.find(({ name }) => name === "skill:identity") ?? {
|
|
name: "skill:identity",
|
|
ok: state === "aligned",
|
|
classification:
|
|
state === "aligned"
|
|
? "healthy"
|
|
: state === "unknown"
|
|
? "unknown"
|
|
: "config_drift",
|
|
...(state === "aligned"
|
|
? { detail: "Installed REA skill identity is aligned." }
|
|
: {
|
|
remediation:
|
|
state === "unmanaged"
|
|
? "Use regular skill directories, or run rea setup with --skill=false to configure the client without installing the skill."
|
|
: state === "unknown"
|
|
? "Verify access to the reported skill files before retrying."
|
|
: "Run rea setup to update the installed REA skill.",
|
|
}),
|
|
};
|
|
|
|
const doctorProviderCheckName = (providerId: string, name: string): boolean =>
|
|
name === providerId || name.startsWith(`${providerId}-`);
|