mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
Track attempted writes, preserve original content and modes from one regular-file observation, and continue rollback after individual failures. Return tagged failure details and retained backup paths through setup. Validation: setup filesystem and recovery tests, combined compile/typecheck/lint/format/metadata/architecture checks. Filesystem fault injection covers backup, ambiguous publication, and rollback failures.
574 lines
18 KiB
TypeScript
574 lines
18 KiB
TypeScript
import type {
|
|
ClientConfigurationResult,
|
|
SetupAction,
|
|
SetupClientState,
|
|
SetupConfirmation,
|
|
SetupConfirmationDecision,
|
|
SetupDoctorSummary,
|
|
SetupHost,
|
|
SetupOptions,
|
|
SetupProgressEvent,
|
|
SetupProviderEnvironment,
|
|
SetupResult,
|
|
} from "./SetupTypes.js";
|
|
import {
|
|
manualRegistrationRemediation,
|
|
type SetupClient,
|
|
} from "./SupportedClients.js";
|
|
import type { DoctorReport, DoctorScope } from "./Doctor.js";
|
|
import { configureDetectedClients } from "./SetupClients.js";
|
|
import { discoverSetupState, planSetupActions } from "./SetupPlan.js";
|
|
import {
|
|
filterClientsNeedingConfigure,
|
|
hostRemediation,
|
|
systemSetupHost,
|
|
} from "./SetupHost.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
import type { SetupSkillInstallError } from "./SetupSkill.js";
|
|
|
|
/** Discover, approve, and apply setup actions idempotently. */
|
|
export const runSetup = async (
|
|
options: SetupOptions,
|
|
host: SetupHost = systemSetupHost(),
|
|
confirm?: SetupConfirmation,
|
|
): Promise<SetupResult> => {
|
|
let result: SetupResult | undefined;
|
|
let primaryFailure: unknown;
|
|
let threw = false;
|
|
try {
|
|
result = await runSetupWorkflow(options, host, confirm);
|
|
} catch (cause: unknown) {
|
|
threw = true;
|
|
primaryFailure = cause;
|
|
}
|
|
|
|
let cleanupFailure: string | undefined;
|
|
try {
|
|
cleanupFailure = await host.close?.();
|
|
} catch (cause: unknown) {
|
|
cleanupFailure = `Setup resource cleanup failed: ${failureMessage(cause)}`;
|
|
}
|
|
|
|
if (threw) {
|
|
if (cleanupFailure !== undefined && primaryFailure instanceof AnalysisError)
|
|
throw analysisErrorWithCleanupFailure(
|
|
primaryFailure,
|
|
new ProviderCleanupError(
|
|
"setup",
|
|
primaryFailure.cleanupResources,
|
|
{ reason: cleanupFailure },
|
|
{ operation: "setup" },
|
|
),
|
|
"setup",
|
|
);
|
|
if (cleanupFailure !== undefined)
|
|
throw new AggregateError(
|
|
[primaryFailure, new Error(cleanupFailure)],
|
|
"Setup and resource cleanup failed.",
|
|
{ cause: primaryFailure },
|
|
);
|
|
throw primaryFailure;
|
|
}
|
|
if (result === undefined) throw new Error("Setup returned no result.");
|
|
if (cleanupFailure === undefined) return result;
|
|
return {
|
|
...result,
|
|
status: "needs_human",
|
|
code: result.code ?? "cleanup_failed",
|
|
remediation: result.remediation
|
|
? `${result.remediation} ${cleanupFailure}`
|
|
: cleanupFailure,
|
|
};
|
|
};
|
|
|
|
const runSetupWorkflow = async (
|
|
options: SetupOptions,
|
|
host: SetupHost,
|
|
confirm?: SetupConfirmation,
|
|
): Promise<SetupResult> => {
|
|
const appliedActions: string[] = [];
|
|
let plannedActions: readonly SetupAction[] = [];
|
|
const clients: Record<string, ClientConfigurationResult> = {};
|
|
let clientStates: readonly SetupClientState[] = [];
|
|
let readinessScope = options.readinessScope;
|
|
const fail = (remediation: string) =>
|
|
setupFailure(
|
|
remediation,
|
|
[host, plannedActions, appliedActions, clients, clientStates],
|
|
readinessScope,
|
|
);
|
|
const unsupported = await hostRemediation(host, false);
|
|
if (unsupported !== undefined) return fail(unsupported);
|
|
const initialState = await host.initialSetupState(options.readinessScope);
|
|
let { hopperPath, providerEnvironment } = initialState;
|
|
const discovery = await discoverSetupState({
|
|
host,
|
|
providerEnvironment,
|
|
forceHopperInstall: options.installHopper,
|
|
proposeHopper:
|
|
options.proposeHopper ?? (confirm !== undefined && !options.structured),
|
|
initialDoctor: initialState.doctor,
|
|
});
|
|
clientStates = discovery.clientStates;
|
|
const clientSelectionAllowed =
|
|
options.clientIds === undefined &&
|
|
options.readinessScope?.clients === undefined &&
|
|
options.allDetectedClients !== true &&
|
|
options.installHopper !== true &&
|
|
options.skillClientIds === undefined &&
|
|
options.installSkill === undefined;
|
|
const interactiveSelection =
|
|
confirm !== undefined && !options.structured && clientSelectionAllowed;
|
|
let selectedClientIds = options.allDetectedClients
|
|
? discovery.detectedClientIds
|
|
: (options.clientIds ??
|
|
options.readinessScope?.clients ??
|
|
discovery.defaultClientIds);
|
|
let selectedSkillClientIds =
|
|
options.skillClientIds ??
|
|
options.readinessScope?.skillClients ??
|
|
selectedClientIds;
|
|
let skillNeedsInstall = await host.skillNeedsInstall(selectedSkillClientIds);
|
|
let skillSelected =
|
|
options.installSkill === true ||
|
|
(options.installSkill !== false &&
|
|
(selectedClientIds.length > 0 || selectedSkillClientIds.length > 0));
|
|
let installSkill = skillSelected && skillNeedsInstall;
|
|
let planDiscovery = discovery;
|
|
if (interactiveSelection) {
|
|
const selectionSkillClientIds = [
|
|
...new Set([
|
|
...discovery.defaultClientIds,
|
|
...discovery.detectedClientIds,
|
|
]),
|
|
];
|
|
const selectionSkillNeedsInstall = await host.skillNeedsInstall(
|
|
selectionSkillClientIds,
|
|
);
|
|
const offerSkillAction =
|
|
options.installSkill !== false && selectionSkillNeedsInstall;
|
|
const selectionActions = await planSetupActions({
|
|
discovery,
|
|
host,
|
|
providerEnvironment,
|
|
command: host.registrationCommand,
|
|
clientIds: [],
|
|
skillClientIds: selectionSkillClientIds,
|
|
installSkill: offerSkillAction,
|
|
});
|
|
const decision = await confirm(selectionActions.plannedActions, {
|
|
stage: "select",
|
|
clientStates,
|
|
selectedClientIds: discovery.defaultClientIds,
|
|
clientSelectionAllowed,
|
|
});
|
|
if (isSetupDecisionCancelled(decision))
|
|
return setupCancelled(
|
|
selectionActions.plannedActions,
|
|
clients,
|
|
clientStates,
|
|
discovery.initialDoctor,
|
|
);
|
|
const selectedActionIds = new Set(
|
|
typeof decision === "boolean"
|
|
? decision
|
|
? [
|
|
...selectionActions.plannedActions.map(({ id }) => id),
|
|
...discovery.defaultClientIds.map(
|
|
(id) => `configure_client:${id}`,
|
|
),
|
|
]
|
|
: []
|
|
: decision.selectedActionIds,
|
|
);
|
|
selectedClientIds = [...selectedActionIds]
|
|
.filter((id) => id.startsWith("configure_client:"))
|
|
.map((id) => id.slice("configure_client:".length));
|
|
skillNeedsInstall = await host.skillNeedsInstall(selectedClientIds);
|
|
const proposedInstallAction = selectionActions.plannedActions.some(
|
|
({ id }) => id === "install_hopper",
|
|
);
|
|
planDiscovery = {
|
|
...discovery,
|
|
installHopper:
|
|
options.installHopper ||
|
|
(proposedInstallAction && selectedActionIds.has("install_hopper")),
|
|
};
|
|
skillSelected =
|
|
options.installSkill === true ||
|
|
(selectedActionIds.has("install_skill") &&
|
|
selectedClientIds.length === 0) ||
|
|
(options.installSkill !== false && selectedClientIds.length > 0);
|
|
installSkill = skillSelected && skillNeedsInstall;
|
|
selectedSkillClientIds = selectedClientIds;
|
|
}
|
|
const planned = await planSetupActions({
|
|
discovery: planDiscovery,
|
|
host,
|
|
providerEnvironment,
|
|
command: host.registrationCommand,
|
|
clientIds: selectedClientIds,
|
|
skillClientIds: selectedSkillClientIds,
|
|
installSkill,
|
|
});
|
|
plannedActions = planned.plannedActions;
|
|
if (planned.blocker !== undefined) return fail(planned.blocker);
|
|
const hopperBlocker = await hostRemediation(
|
|
host,
|
|
planDiscovery.installHopper,
|
|
);
|
|
if (hopperBlocker !== undefined) return fail(hopperBlocker);
|
|
const planSelection = {
|
|
installHopper: planDiscovery.installHopper,
|
|
installSkill,
|
|
skillSelected,
|
|
selectedSkillClientIds,
|
|
selectedClients: planned.selectedClients,
|
|
clientsToConfigure: planned.clientsToConfigure,
|
|
};
|
|
readinessScope = resolvedReadinessScope(
|
|
options.readinessScope,
|
|
planSelection,
|
|
);
|
|
|
|
const manualRemediation = manualClientsRemediation(planned.selectedClients);
|
|
if (options.dryRun === true)
|
|
return {
|
|
status: "planned",
|
|
plannedActions,
|
|
appliedActions,
|
|
clients,
|
|
doctor: summarizeDoctor(discovery.initialDoctor),
|
|
clientStates,
|
|
...(manualRemediation === undefined
|
|
? {}
|
|
: { remediation: manualRemediation }),
|
|
};
|
|
|
|
let approved = options.approved || plannedActions.length === 0;
|
|
let interactiveApproval = false;
|
|
if (!approved && confirm !== undefined && !options.structured) {
|
|
const decision = await confirm?.(plannedActions, {
|
|
stage: "confirm",
|
|
clientStates,
|
|
selectedClientIds,
|
|
clientSelectionAllowed: false,
|
|
});
|
|
if (decision !== undefined) {
|
|
if (isSetupDecisionCancelled(decision))
|
|
return setupCancelled(
|
|
plannedActions,
|
|
clients,
|
|
clientStates,
|
|
discovery.initialDoctor,
|
|
);
|
|
approved = typeof decision === "boolean" ? decision : decision.approved;
|
|
interactiveApproval = approved;
|
|
}
|
|
}
|
|
if (!approved)
|
|
return {
|
|
status: confirm === undefined ? "needs_confirmation" : "cancelled",
|
|
plannedActions,
|
|
appliedActions,
|
|
clients,
|
|
doctor: summarizeDoctor(discovery.initialDoctor),
|
|
clientStates,
|
|
...(confirm === undefined
|
|
? {
|
|
remediation:
|
|
"Review the setup plan, then rerun interactively or with --yes.",
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
let selectedClients: readonly SetupClient[] =
|
|
planSelection.clientsToConfigure;
|
|
if (
|
|
planSelection.installHopper &&
|
|
(interactiveApproval || options.installHopper)
|
|
) {
|
|
const install = await installHopperAction({
|
|
host,
|
|
options,
|
|
plannedActions,
|
|
appliedActions,
|
|
clients,
|
|
selectedClients,
|
|
providerEnvironment,
|
|
doctorScope: readinessScope,
|
|
clientStates,
|
|
});
|
|
if ("failure" in install) return install.failure;
|
|
({ hopperPath, providerEnvironment, selectedClients } = install);
|
|
}
|
|
const clientFailure = await configureDetectedClients({
|
|
host,
|
|
detectedClients: selectedClients,
|
|
providerEnvironment,
|
|
command: host.registrationCommand,
|
|
clients,
|
|
appliedActions,
|
|
...(options.onProgress === undefined
|
|
? {}
|
|
: { onProgress: options.onProgress }),
|
|
});
|
|
if (clientFailure !== undefined) return fail(clientFailure);
|
|
if (planSelection.installSkill) {
|
|
const skillFailure = await installSkillAction(
|
|
host,
|
|
options,
|
|
appliedActions,
|
|
selectedSkillClientIds,
|
|
);
|
|
if (skillFailure !== undefined) {
|
|
return {
|
|
...(await fail(skillFailure.remediation)),
|
|
code: "skill_install_failed",
|
|
};
|
|
}
|
|
}
|
|
const doctor = summarizeDoctor(await host.doctor(readinessScope));
|
|
const remediation = combineRemediation(
|
|
finalSetupRemediation(
|
|
host.platform,
|
|
appliedActions.includes("installed_hopper"),
|
|
doctor.healthy,
|
|
hopperPath,
|
|
),
|
|
manualRemediation,
|
|
);
|
|
return {
|
|
status: remediation === undefined ? "ready" : "needs_human",
|
|
plannedActions,
|
|
appliedActions,
|
|
clients,
|
|
doctor,
|
|
clientStates,
|
|
...(remediation === undefined ? {} : { remediation }),
|
|
};
|
|
};
|
|
|
|
const installHopperAction = async (input: {
|
|
readonly host: SetupHost;
|
|
readonly options: SetupOptions;
|
|
readonly plannedActions: readonly SetupAction[];
|
|
readonly appliedActions: string[];
|
|
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
|
|
readonly selectedClients: readonly SetupClient[];
|
|
readonly providerEnvironment: SetupProviderEnvironment;
|
|
readonly doctorScope: DoctorScope | undefined;
|
|
readonly clientStates: readonly SetupClientState[];
|
|
}) => {
|
|
const label = "Hopper deep-analysis provider";
|
|
emitProgress(input.options, {
|
|
actionId: "install_hopper",
|
|
label,
|
|
state: "started",
|
|
});
|
|
const installed = await input.host.installHopper(input.options.installHopper);
|
|
if (installed.status === "failed") {
|
|
emitProgress(input.options, {
|
|
actionId: "install_hopper",
|
|
label,
|
|
state: "failed",
|
|
detail: installed.remediation,
|
|
});
|
|
return {
|
|
failure: {
|
|
status: "needs_human" as const,
|
|
plannedActions: input.plannedActions,
|
|
appliedActions: input.appliedActions,
|
|
clients: input.clients,
|
|
doctor: summarizeDoctor(await input.host.doctor(input.doctorScope)),
|
|
clientStates: input.clientStates,
|
|
code: installed.code,
|
|
remediation: installed.remediation,
|
|
},
|
|
};
|
|
}
|
|
input.appliedActions.push("installed_hopper");
|
|
emitProgress(input.options, {
|
|
actionId: "install_hopper",
|
|
label,
|
|
state: installed.cleanupFailure === undefined ? "completed" : "warning",
|
|
detail:
|
|
installed.cleanupFailure === undefined
|
|
? installed.launcherPath
|
|
: `${installed.launcherPath}; ${installed.cleanupFailure}`,
|
|
});
|
|
const providerEnvironment = {
|
|
...input.providerEnvironment,
|
|
HOPPER_LAUNCHER_PATH: installed.launcherPath,
|
|
};
|
|
return {
|
|
hopperPath: installed.launcherPath,
|
|
providerEnvironment,
|
|
selectedClients: await filterClientsNeedingConfigure(
|
|
input.host,
|
|
input.selectedClients,
|
|
providerEnvironment,
|
|
input.host.registrationCommand,
|
|
),
|
|
};
|
|
};
|
|
|
|
const installSkillAction = async (
|
|
host: SetupHost,
|
|
options: SetupOptions,
|
|
appliedActions: string[],
|
|
skillClientIds: readonly string[],
|
|
): Promise<SetupSkillInstallError | undefined> => {
|
|
const label = "REA reverse-engineering skill";
|
|
emitProgress(options, {
|
|
actionId: "install_skill",
|
|
label,
|
|
state: "started",
|
|
});
|
|
const skill = await host.installSkill(skillClientIds);
|
|
if (!skill.ok) {
|
|
emitProgress(options, {
|
|
actionId: "install_skill",
|
|
label,
|
|
state: "failed",
|
|
detail: skill.error.message,
|
|
});
|
|
return skill.error;
|
|
}
|
|
if (skill.value === "installed") appliedActions.push("installed_skill");
|
|
emitProgress(options, {
|
|
actionId: "install_skill",
|
|
label,
|
|
state: skill.value === "installed" ? "completed" : "warning",
|
|
...(skill.value === "unchanged" ? { detail: "Already current" } : {}),
|
|
});
|
|
return undefined;
|
|
};
|
|
|
|
const setupFailure = async (
|
|
remediation: string,
|
|
[host, plannedActions, appliedActions, clients, clientStates]: readonly [
|
|
SetupHost,
|
|
readonly SetupAction[],
|
|
readonly string[],
|
|
Readonly<Record<string, ClientConfigurationResult>>,
|
|
readonly SetupClientState[],
|
|
],
|
|
scope?: DoctorScope,
|
|
): Promise<SetupResult> => {
|
|
return {
|
|
status: "needs_human",
|
|
plannedActions,
|
|
appliedActions,
|
|
clients,
|
|
doctor: summarizeDoctor(await host.doctor(scope)),
|
|
clientStates,
|
|
remediation,
|
|
};
|
|
};
|
|
|
|
const emitProgress = (options: SetupOptions, event: SetupProgressEvent): void =>
|
|
options.onProgress?.(event);
|
|
|
|
const manualClientsRemediation = (
|
|
clients: readonly SetupClient[],
|
|
): string | undefined =>
|
|
clients
|
|
.map((client) => manualRegistrationRemediation(client.name))
|
|
.find((remediation) => remediation !== undefined);
|
|
|
|
const combineRemediation = (
|
|
...parts: readonly (string | undefined)[]
|
|
): string | undefined => {
|
|
const present = parts.filter(
|
|
(part): part is string => part !== undefined && part !== "",
|
|
);
|
|
return present.length === 0 ? undefined : present.join(" ");
|
|
};
|
|
|
|
const finalSetupRemediation = (
|
|
platform: NodeJS.Platform,
|
|
installedHopper: boolean,
|
|
healthy: boolean,
|
|
hopperPath: string | undefined,
|
|
): string | undefined => {
|
|
if (platform === "darwin" && installedHopper)
|
|
return "Open Hopper, choose its demo mode or activate a license, then rerun rea doctor --json.";
|
|
if (healthy) return undefined;
|
|
return hopperPath === undefined
|
|
? "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis."
|
|
: "Run rea doctor and apply each reported remediation.";
|
|
};
|
|
|
|
const isSetupDecisionCancelled = (
|
|
decision: boolean | SetupConfirmationDecision,
|
|
): boolean =>
|
|
typeof decision === "boolean" ? !decision : decision.cancelled === true;
|
|
|
|
const setupCancelled = (
|
|
plannedActions: readonly SetupAction[],
|
|
clients: Readonly<Record<string, ClientConfigurationResult>>,
|
|
clientStates: readonly SetupClientState[],
|
|
doctor: DoctorReport,
|
|
): SetupResult => ({
|
|
status: "cancelled",
|
|
plannedActions,
|
|
appliedActions: [],
|
|
clients,
|
|
doctor: summarizeDoctor(doctor),
|
|
clientStates,
|
|
});
|
|
|
|
const resolvedReadinessScope = (
|
|
requested: DoctorScope | undefined,
|
|
selection: {
|
|
readonly selectedClients: readonly SetupClient[];
|
|
readonly installHopper: boolean;
|
|
readonly skillSelected?: boolean;
|
|
readonly selectedSkillClientIds: readonly string[];
|
|
},
|
|
): DoctorScope => ({
|
|
clients: selection.selectedClients.map(({ name }) => name),
|
|
providers: selection.installHopper
|
|
? ["hopper"]
|
|
: (requested?.providers ?? []),
|
|
skill: selection.skillSelected === true || requested?.skill === true,
|
|
...(selection.skillSelected === true || requested?.skill === true
|
|
? { skillClients: selection.selectedSkillClientIds }
|
|
: {}),
|
|
});
|
|
|
|
const summarizeDoctor = (report: DoctorReport): SetupDoctorSummary => ({
|
|
healthy: report.healthy,
|
|
environment_healthy: report.environment_healthy,
|
|
scope: report.scope,
|
|
availableProviders: [
|
|
...(report.hopperPath !== undefined &&
|
|
report.checks
|
|
.filter(({ name }) => name.startsWith("hopper"))
|
|
.every(({ ok }) => ok)
|
|
? ["hopper"]
|
|
: []),
|
|
...(report.providerInspections ?? [])
|
|
.filter(({ available }) => available)
|
|
.map(({ id }) => id),
|
|
],
|
|
...(report.hopperPath === undefined ? {} : { hopperPath: report.hopperPath }),
|
|
...(report.providerInspections === undefined
|
|
? {}
|
|
: { providerInspections: report.providerInspections }),
|
|
...(report.identity === undefined
|
|
? {}
|
|
: {
|
|
identity: {
|
|
skill: report.identity.skill,
|
|
registrations: report.identity.registrations,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const failureMessage = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|