Files
rea/src/application/Setup.ts
T
Dery Ferdika 98ec00f406 fix(setup): preserve cleanup ownership and resource reports (#6920)
* fix(setup): report host-owned cleanup resources alongside the primary's

SetupHost.close documents that it reports the resources a setup host still
owns, but its contract returned only a string. runSetup therefore built the
ProviderCleanupError from the primary failure's cleanupResources alone, so a
resource the host failed to release reached the projection only inside a
free-text reason while the error asks the caller to review the reported local
resources.

Return a SetupCloseReport instead. closeSetupResources absorbs the structured
resources of any cause that reports them, through
ArtifactReaderFailure.cleanupObservation, and leaves opaque failures
message-only so no resource name is invented. runSetup then unions the host's
resources with the primary's before building the typed cleanup error.

Two boundary tests read the close result directly: the skill-cleanup case now
asserts the retained backup path in the report reason rather than in a bare
string, and the shared ownership helper takes an opaque close result because it
only ever awaits undefined from either host it is given.

Validation: RED then green in src/application/Setup.recovery.test.ts, where
the new case previously received only the primary resource; 116 tests across
the seven Setup and cleanup-ownership suites pass; tsc --noEmit over the
changed set, its setup callers, and both boundary tests exits 0; oxlint
reports 0 errors; oxfmt --check is clean; verify:module-boundaries passes.
The two failures in src/setupEnvironment.test.ts reproduce identically on
pristine bded9c628 with this change stashed.

* fix(setup): preserve cleanup ownership through host reports

* fix(setup): bind finalization reports to cleanup owners

* fix(setup): retain live resources in returned cleanup views

* fix(setup): preserve completed cleanup and exact installer resources

* fix(setup): retain delegated cleanup across authority changes

* fix(setup): compose callback and source cleanup authorities

* fix(setup): retain producer cleanup failure reasons

* fix(cli): render structured setup cleanup resources

* fix(setup): separate failure evidence from cleanup ownership

* fix(setup): normalize close failures and unify lease binding

* fix(setup): preserve direct lease resource attribution

---------

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-24 15:08:10 +08:00

617 lines
20 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,
hopperInstallRefusal,
planSetupActions,
} from "./SetupPlan.js";
import {
filterClientsNeedingConfigure,
hostRemediation,
systemSetupHost,
} from "./SetupHost.js";
import {
AnalysisError,
type AnalysisCleanupObservation,
} from "../domain/analysisErrorBase.js";
import { setupCleanupError, setupCleanupReason } from "./SetupCleanupOwner.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import type { SetupSkillInstallError } from "./SetupSkill.js";
import type { SetupHopperInstallFailure } from "./SetupInstallFailure.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 cleanup: AnalysisError | undefined;
try {
const report = await host.close?.();
cleanup = report === undefined ? undefined : setupCleanupError(report);
} catch (cause: unknown) {
cleanup = setupCleanupError(cause);
}
if (cleanup !== undefined && !cleanup.cleanupIncomplete) cleanup = undefined;
if (threw) {
if (cleanup === undefined) throw primaryFailure;
if (primaryFailure instanceof AnalysisError)
throw analysisErrorWithCleanupFailure(
primaryFailure,
cleanup,
"setup",
).bindCleanupDependencies([primaryFailure, cleanup]);
throw new AggregateError(
[primaryFailure, cleanup],
"Setup and resource cleanup failed.",
{ cause: primaryFailure },
);
}
if (result === undefined) throw new Error("Setup returned no result.");
if (cleanup === undefined) return result;
const cleanupReason = setupCleanupReason(cleanup);
const cleanupReport: AnalysisCleanupObservation = {
reason: cleanupReason,
get resources() {
return cleanup.cleanupResources;
},
};
return {
...result,
status: "needs_human",
code: result.code ?? "cleanup_failed",
get cleanup() {
return cleanup.cleanupIncomplete ? cleanupReport : undefined;
},
remediation: result.remediation
? `${result.remediation} ${cleanupReason}`
: cleanupReason,
};
};
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 failSkill = async (
error: SetupSkillInstallError,
): Promise<SetupResult> => ({
...(await fail(error.remediation)),
code: "skill_install_failed",
});
const unsupported = hostRemediation(host);
if (unsupported !== undefined) return fail(unsupported);
const initialState = await host.initialSetupState(options.readinessScope);
let { hopperPath, providerEnvironment } = initialState;
const discovery = await discoverSetupState({
host,
providerConfigured: initialState.providerConfigured,
forceHopperInstall: options.installHopper,
hopperLauncherConfigured: initialState.hopperLauncherConfigured === true,
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;
const requestedClientIds =
options.clientIds ?? options.readinessScope?.clients;
let selectedClientIds = [
...new Set(
options.allDetectedClients
? [...(requestedClientIds ?? []), ...discovery.detectedClientIds]
: (requestedClientIds ?? 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) {
// The skill choice is intent; its final destination is determined after
// selecting clients. An empty client selection names the shared CLI copy.
const selectionSkillClientIds: readonly string[] = [];
const offerSkillAction = options.installSkill !== false;
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")),
};
// The offered skill action can carry a client-specific ID (for example
// `install_skill:claude_code`); without a selected client it means the
// shared CLI skill.
const skillActionSelected = selectionActions.plannedActions.some(
({ id, kind }) => kind === "install_skill" && selectedActionIds.has(id),
);
skillSelected =
options.installSkill === true ||
(skillActionSelected && 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 hopperRefusal = hopperInstallRefusal(planDiscovery);
if (hopperRefusal !== undefined) return fail(hopperRefusal);
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 (skillSelected) {
const admission = await host.validateSkillDirectories(
selectedSkillClientIds,
);
if (!admission.ok) return failSkill(admission.error);
}
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.",
}
: {}),
};
// Approval may pause while a skill manager changes a selected directory.
if (planSelection.skillSelected) {
const admission = await host.validateSkillDirectories(
selectedSkillClientIds,
);
if (!admission.ok) return failSkill(admission.error);
}
let selectedClients: readonly SetupClient[] =
planSelection.clientsToConfigure;
let hopperWarning: string | undefined;
let hopperFailure: SetupHopperInstallFailure | undefined;
if (
planSelection.installHopper &&
(interactiveApproval || options.installHopper)
) {
const install = await installHopperAction({
host,
options,
appliedActions,
selectedClients,
providerEnvironment,
});
// Hopper is optional: a failed install still applies the client
// registrations and skill approved with it (#5113).
if ("failure" in install) hopperFailure = install.failure;
else
({
hopperPath,
providerEnvironment,
selectedClients,
warning: hopperWarning,
} = install);
}
const clientFailure = await configureDetectedClients({
host,
detectedClients: selectedClients,
providerEnvironment,
command: host.registrationCommand,
clients,
appliedActions,
...(options.onProgress === undefined
? {}
: { onProgress: options.onProgress }),
});
// A later failure must not hide a failed Hopper install: keep its repair
// steps, and its code when the later failure carries none.
const withHopperFailure = (result: SetupResult): SetupResult =>
hopperFailure === undefined
? result
: {
...result,
code: result.code ?? hopperFailure.code,
remediation:
combineRemediation(hopperFailure.remediation, result.remediation) ??
hopperFailure.remediation,
};
if (clientFailure !== undefined)
return withHopperFailure(await fail(clientFailure));
if (planSelection.installSkill) {
const skillFailure = await installSkillAction(
host,
options,
appliedActions,
selectedSkillClientIds,
);
if (skillFailure !== undefined) {
return withHopperFailure(await failSkill(skillFailure));
}
}
const doctor = summarizeDoctor(await host.doctor(readinessScope));
const remediation = combineRemediation(
hopperFailure?.remediation ??
finalSetupRemediation(
host.platform,
appliedActions.includes("installed_hopper"),
doctor.healthy,
// Offer installation only where the explicit gate accepts it.
hopperPath === undefined && planDiscovery.hopperInstall.supported,
),
hopperWarning,
manualRemediation,
);
return {
status: remediation === undefined ? "ready" : "needs_human",
plannedActions,
appliedActions,
clients,
doctor,
clientStates,
...(remediation === undefined ? {} : { remediation }),
...(hopperFailure === undefined ? {} : { code: hopperFailure.code }),
};
};
const installHopperAction = async (input: {
readonly host: SetupHost;
readonly options: SetupOptions;
readonly appliedActions: string[];
readonly selectedClients: readonly SetupClient[];
readonly providerEnvironment: SetupProviderEnvironment;
}) => {
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: installed };
}
input.appliedActions.push("installed_hopper");
const warning = [installed.warning, installed.cleanupFailure]
.filter((value): value is string => value !== undefined)
.join(" ");
emitProgress(input.options, {
actionId: "install_hopper",
label,
state: warning === "" ? "completed" : "warning",
detail:
warning === ""
? installed.launcherPath
: `${installed.launcherPath}; ${warning}`,
});
const providerEnvironment = {
...input.providerEnvironment,
HOPPER_LAUNCHER_PATH: installed.launcherPath,
};
return {
hopperPath: installed.launcherPath,
providerEnvironment,
...(warning === "" ? {} : { warning }),
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,
offerHopperInstall: boolean,
): 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 offerHopperInstall
? "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, checks }) => available && checks.every(({ ok }) => ok),
)
.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);