mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
* refactor(errors): read cleanup completion from dependencies instead of pushing it to dependents AnalysisError.cleanupIncomplete becomes a getter over the error's own unresolved state and its bound dependencies. The WeakRef dependents graph and resolveCleanup's push propagation are removed, and subclasses mark their initial state through markCleanupIncomplete instead of overriding a public mutable field. * fix(errors): settle bound dependencies before binding more A composite whose dependencies had released but whose status was never read stayed incomplete when a later dependency was bound. * refactor(errors): let errors read a lease group's release instead of resolving them (#10759) AnalysisCleanupLeaseGroup no longer calls resolveCleanup() on its error. The error holds the group as a release claim and reads cleanupReleased when asked, so completion follows the lease owner without a push. * chore(verify): count resolveCleanup and bindCleanupDependencies calls so they only shrink (#10763) * refactor(errors): let errors read a lease group's release instead of resolving them AnalysisCleanupLeaseGroup no longer calls resolveCleanup() on its error. The error holds the group as a release claim and reads cleanupReleased when asked, so completion follows the lease owner without a push. * chore(verify): count resolveCleanup and bindCleanupDependencies calls so they only shrink * refactor(process): let worker cleanup errors read their root epoch's release OwnedWorkerRuntime's root epoch is now an AnalysisCleanupReleaseClaim. Errors bound to it read its release when asked instead of the epoch pushing resolveCleanup() through weak references, and a runtime without a retained root binds an already-released owner. Removes the OwnedWorkerCleanupFailure structural type and four resolveCleanup() pushes; the counted baseline shrinks accordingly. * refactor(pcap): let pcap cleanup errors read command and snapshot release (#10792) Retained pcap commands, retained snapshot failures and snapshot completion descriptors now own their release and errors read it: commands and snapshot failures implement AnalysisCleanupReleaseClaim, and a failed completion-descriptor close is retried through an AnalysisCleanupLease bound to its error. Removes four resolveCleanup() pushes; the counted baseline shrinks accordingly. * refactor(process): let Darwin helper cleanup errors read their owner's release Darwin helper cache and run-token reader root cleanup now record an AnalysisCleanupRelease that every failure reports; a successful retry confirms it. Replaces the first-failure anchor that later failures depended on and the resolveCleanup() push. Removes two resolveCleanup() and two bindCleanupDependencies() calls; both counted baselines shrink. * refactor(ghidra): let Ghidra cleanup errors read the client's and checkpoint's release GhidraClient and GhidraCheckpoint bind each cleanup failure to an AnalysisCleanupRelease and confirm it when a later close or retry releases everything, instead of keeping a set of failures and pushing resolveCleanup() to each. Removes two resolveCleanup() calls; the counted baseline shrinks. * refactor(process): let debugger session cleanup errors read a confirmed stop OwnedDebuggerSessionRegistry binds each retained gdb or rizin cleanup failure to the session's AnalysisCleanupRelease and confirms it when a stop is confirmed, keeping the latest failure for retry reporting, instead of pushing resolveCleanup() to a set of failures. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(process): drop the debugger registry's unused closeOperation option Returning the latest retained failure removed the last reader of closeOperation; remove the option and the values gdb and rizin supplied. * refactor(server): let environment shutdown errors read a successful close serveEnvironment binds each shutdown failure to an AnalysisCleanupRelease that a later successful close confirms, instead of pushing resolveCleanup() to a set of failures. The state-file retry test now also requires the earlier report to read the release. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(desktop): release retained screen-judge cleanup through its lease Each retained judge or screenshot cleanup owner is wrapped in an AnalysisCleanupLease bound to its failure; close() releases the lease, which records the release the failure reads, instead of closing the owner and pushing resolveCleanup(). Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(ida): let IDA close failures read the session's later close IdaSessionClient's close failure binds an owner that is both its resource owner and its release claim, read from the client's closed state, instead of keeping weak references and pushing resolveCleanup() after a successful retry. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(javascript): let source-map codec cleanup errors read a later close (#10814) * refactor(javascript): let source-map codec cleanup errors read a later close OwnedCodec binds each close failure to an AnalysisCleanupRelease that a later successful close confirms, replacing the first-failure anchor that later failures depended on and the resolveCleanup() push. Removes one resolveCleanup() and one bindCleanupDependencies() call; both counted baselines shrink. * refactor(cutter): let pending Cutter command errors read the completion receipt (#10815) * refactor(cutter): let pending Cutter command errors read the completion receipt Each pending Cutter command keeps an AnalysisCleanupRelease bound to its timeout failure; the producer's terminal completion receipt confirms it instead of pushing resolveCleanup(). Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(application): let provider candidate cleanup errors read the candidate's close (#10817) * refactor(application): let provider candidate cleanup errors read the candidate's close AnalysisProviderRegistry keeps one AnalysisCleanupRelease per candidate whose resource close is unconfirmed; failures bind to it and a later confirmed close confirms it, instead of pushing resolveCleanup() to a set of failures. Removes one resolveCleanup() call; the counted baseline shrinks. * fix(errors): bind a cleanup release claim to an error at most once AnalysisError kept release claims in an array, so a provider whose retried close returned the same error bound the same claim again on every retry and each cleanupIncomplete read scanned the duplicates. Keep the claims in a set. * refactor(application): let reverse-tool cleanup errors read a verified stop (#10819) * refactor(application): let reverse-tool cleanup errors read a verified stop Each retained provider process group in ReverseEngineeringService keeps an AnalysisCleanupRelease; its command and retry failures bind to it and a later verified stop confirms it, instead of collecting failures and pushing resolveCleanup(). Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(sqlite): let SQLite worker and root cleanup errors read their release (#10821) Each retained SQLite snapshot cleanup records an AnalysisCleanupRelease for its worker and for its runtime root; the resource failures bind to them and a verified stop or root removal confirms them, instead of pushing resolveCleanup(). Composite failures keep reading those resource failures. Removes two resolveCleanup() calls; the counted baseline shrinks. * refactor(android): let JADX cleanup errors read their retained cleanup's release Each retained JADX cleanup records an AnalysisCleanupRelease for the admission failure's descriptor owners and one for the whole retained cleanup. The admission failure and every error the cleanup reports bind to them, and a confirmed close confirms them, instead of pushing resolveCleanup() through WeakRefs to reported errors. Removes two resolveCleanup() calls; the counted baseline shrinks. * refactor(application): let session close failures read the client's later close A retained session cleanup records an AnalysisCleanupRelease; unowned cleanup failures the client's close reported bind to it and the successful close confirms it, instead of the session collecting them and pushing resolveCleanup(). Failures that already follow their own owner keep reading it. Adds a regression for an unowned close failure. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(desktop): let build-computer and agentenv cleanup errors read their owner's release Build-computer teardown, retained task readers, remote reader folders and agentenv solve sandboxes each record an AnalysisCleanupRelease that their bound failures read; a verified teardown or stop confirms it instead of pushing resolveCleanup() through WeakRef sets. Long-run job failures read whether every job they name has confirmed its stop, so a failure naming two jobs no longer reads complete when only one stops (regression added). WeakRef sets kept only where late facts (completion receipts, reader views) are still retained as diagnostics. Removes six resolveCleanup() calls; the counted baseline shrinks. * refactor(kuna): let source-map and Kuna cache cleanup errors read their owner's release (#10856) * refactor(browser): let late source-map fetch errors read their response's release A pending source-map fetch and a response cancellation each record an AnalysisCleanupRelease that their cancellation or deadline failure reads; releasing the late response or the reader lock confirms it instead of pushing resolveCleanup(). Removes two resolveCleanup() calls; the counted baseline shrinks. * refactor(kuna): let project-cache cleanup errors read each owner's release Each retained Kuna resource owner (process, staging, hash descriptor, retired file) records a release that the errors reporting it read; an error completes once every owner it reports confirms, and retirement that moves staging to a retained file forwards the staging owner's release to the file's. Replaces the WeakRef report list, its pruning and the resolveCleanup() push. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(process): forbid resolveCleanup() pushes; native markers read their supervisor's release (#10857) * refactor(providers): let an attributed client failure read its source's completed cleanup Re-attributing a failure whose source cleanup already completed now binds a claim reading that source's release instead of pushing resolveCleanup() onto the new error; a pending source is still bound as a dependency. Resources stay as history, as before. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(process): let native command markers read their supervisor's release ProviderProcessSupervisor records an AnalysisCleanupRelease that a stop proving the process ended confirms. Native command and LLDB resolver cleanup markers bind to their supervisor's release, so a verified stop by either owner completes them, instead of each owner pushing resolveCleanup() onto the markers it tracks. Removes the last two resolveCleanup() calls outside the error base; the counted baseline is empty. * chore(verify): forbid resolveCleanup() pushes outside the error base Every production owner now confirms a release its errors read, so the counted baseline is empty. The guard becomes an outright ban with no baseline entry. * test(native): complete native command and LLDB markers through a direct supervisor stop * refactor(errors): complete an error's cleanup from its owner's record and every dependency (#10892) * refactor(errors): complete an error's cleanup from its owner's record and every dependency * refactor(errors): complete test errors through an owner release and delete resolveCleanup() (#10898) * refactor(android): compose JADX and Apktool cleanup failures through one shared owner (#10900) * refactor(android): compose JADX and Apktool cleanup failures through one shared owner * refactor(application): compose service cleanup failures through the shared owner (#10902)
785 lines
25 KiB
TypeScript
785 lines
25 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 { analysisErrorWithCleanupDependencies } from "../domain/analysisErrorCleanup.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
cancelledBy,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
import { assertSetupActive } from "./SetupCancellation.js";
|
|
import type {
|
|
SetupSkillInstallError,
|
|
SkillInstallResult,
|
|
} from "./SetupSkill.js";
|
|
import { planDesktopBuild } from "./SetupDesktopBuild.js";
|
|
import { DESKTOP_BUILD_PROVIDER_ID } from "../domain/doctorProviderIds.js";
|
|
import type {
|
|
SetupHopperInstallFailure,
|
|
SetupHopperInstallResult,
|
|
} from "./SetupInstallFailure.js";
|
|
|
|
/** Completed setup facts retained if a later step is cancelled. */
|
|
interface SetupProgress {
|
|
plannedActions: readonly SetupAction[];
|
|
appliedActions: string[];
|
|
clients: Record<string, ClientConfigurationResult>;
|
|
clientStates: readonly SetupClientState[];
|
|
desktopFailure?: string | undefined;
|
|
hopperOutcome?: SetupHopperInstallResult | undefined;
|
|
skill?: SkillInstallResult | undefined;
|
|
}
|
|
|
|
const setupProgressFacts = (progress: SetupProgress) => {
|
|
const skill = progress.skill;
|
|
return jsonValueSchema.parse({
|
|
plannedActions: progress.plannedActions,
|
|
appliedActions: progress.appliedActions,
|
|
clients: progress.clients,
|
|
clientStates: progress.clientStates,
|
|
...(progress.desktopFailure === undefined
|
|
? {}
|
|
: { desktopFailure: progress.desktopFailure }),
|
|
...(progress.hopperOutcome === undefined
|
|
? {}
|
|
: { hopperOutcome: progress.hopperOutcome }),
|
|
...(skill === undefined
|
|
? {}
|
|
: {
|
|
skill: skill.ok
|
|
? { status: skill.value }
|
|
: {
|
|
status: "failed",
|
|
path: skill.error.path,
|
|
stage: skill.error.stage,
|
|
reason: skill.error.reason,
|
|
rollbackFailures: skill.error.rollbackFailures,
|
|
recoveryBackupPaths: skill.error.recoveryBackupPaths,
|
|
remediation: skill.error.remediation,
|
|
},
|
|
}),
|
|
});
|
|
};
|
|
|
|
/** Discover, approve, and apply setup actions idempotently. */
|
|
export const runSetup = async (
|
|
options: SetupOptions,
|
|
host: SetupHost = systemSetupHost(),
|
|
confirm?: SetupConfirmation,
|
|
): Promise<SetupResult> => {
|
|
const progress: SetupProgress = {
|
|
plannedActions: [],
|
|
appliedActions: [],
|
|
clients: {},
|
|
clientStates: [],
|
|
};
|
|
let result: SetupResult | undefined;
|
|
const pending: { skill?: SetupSkillInstallError } = {};
|
|
let primaryFailure: unknown;
|
|
let threw = false;
|
|
try {
|
|
result = await runSetupWorkflow(options, host, progress, confirm, pending);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof AnalysisCancelledError)
|
|
cause.retainDiagnostics({ setup_progress: setupProgressFacts(progress) });
|
|
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 analysisErrorWithCleanupDependencies(
|
|
primaryFailure,
|
|
cleanup,
|
|
"setup",
|
|
);
|
|
throw new AggregateError(
|
|
[primaryFailure, cleanup],
|
|
"Setup and resource cleanup failed.",
|
|
{ cause: primaryFailure },
|
|
);
|
|
}
|
|
if (result === undefined) throw new Error("Setup returned no result.");
|
|
if (pending.skill !== undefined) {
|
|
const settled =
|
|
pending.skill.cleanupOwners.length > 0 &&
|
|
pending.skill.cleanupOwners.every(({ lease }) => lease.released);
|
|
if (settled)
|
|
emitProgress(options, {
|
|
actionId: "install_skill",
|
|
label: "REA reverse-engineering skill",
|
|
state: "completed",
|
|
});
|
|
else
|
|
result = {
|
|
...result,
|
|
status: "needs_human",
|
|
code: result.code ?? "skill_install_failed",
|
|
remediation:
|
|
combineRemediation(pending.skill.remediation, result.remediation) ??
|
|
pending.skill.remediation,
|
|
};
|
|
}
|
|
// Cancellation observed only during the final doctor or cleanup is not success.
|
|
if (options.signal?.aborted === true) {
|
|
const cancelled = cancelledBy("setup", options.signal, {
|
|
diagnostics: { setup_result: jsonValueSchema.parse(result) },
|
|
});
|
|
if (cleanup !== undefined)
|
|
throw analysisErrorWithCleanupDependencies(cancelled, cleanup, "setup");
|
|
throw cancelled;
|
|
}
|
|
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,
|
|
progress: SetupProgress,
|
|
confirm: SetupConfirmation | undefined,
|
|
pending: { skill?: SetupSkillInstallError },
|
|
): Promise<SetupResult> => {
|
|
assertSetupActive(options.signal);
|
|
const appliedActions = progress.appliedActions;
|
|
let plannedActions: readonly SetupAction[] = [];
|
|
const clients = progress.clients;
|
|
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,
|
|
});
|
|
// Catalog loading and family scope are registration settings, not detected
|
|
// provider facts; merge them after discovery so they cannot suppress the
|
|
// Hopper installation offer on an otherwise unconfigured machine.
|
|
if (options.toolFamilies !== undefined)
|
|
providerEnvironment = {
|
|
...providerEnvironment,
|
|
REA_MCP_TOOL_FAMILIES: options.toolFamilies,
|
|
};
|
|
if (options.toolCatalogProfile !== undefined)
|
|
providerEnvironment = {
|
|
...providerEnvironment,
|
|
REA_MCP_TOOL_CATALOG_PROFILE: options.toolCatalogProfile,
|
|
};
|
|
clientStates = discovery.clientStates;
|
|
progress.clientStates = 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,
|
|
});
|
|
assertSetupActive(options.signal);
|
|
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 desktop = await planDesktopBuild(
|
|
host.desktopBuild,
|
|
options,
|
|
selectedClientIds,
|
|
);
|
|
if (desktop.blocker !== undefined) return fail(desktop.blocker);
|
|
const planned = await planSetupActions({
|
|
discovery: planDiscovery,
|
|
host,
|
|
providerEnvironment,
|
|
command: host.registrationCommand,
|
|
clientIds: selectedClientIds,
|
|
skillClientIds: selectedSkillClientIds,
|
|
installSkill,
|
|
clientEnvironments: desktop.clientEnvironments,
|
|
});
|
|
plannedActions = [...desktop.actions, ...planned.plannedActions];
|
|
progress.plannedActions = 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,
|
|
desktop.selected,
|
|
);
|
|
|
|
const manualRemediation = combineRemediation(
|
|
manualClientsRemediation(planned.selectedClients),
|
|
desktop.remediation,
|
|
);
|
|
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",
|
|
...(manualRemediation === undefined
|
|
? {}
|
|
: { remediation: manualRemediation }),
|
|
clientStates,
|
|
selectedClientIds,
|
|
clientSelectionAllowed: false,
|
|
});
|
|
assertSetupActive(options.signal);
|
|
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);
|
|
}
|
|
assertSetupActive(options.signal);
|
|
const desktopFailure = await desktop.apply(
|
|
appliedActions,
|
|
options.onProgress,
|
|
options.signal,
|
|
);
|
|
progress.desktopFailure = desktopFailure;
|
|
assertSetupActive(options.signal);
|
|
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,
|
|
clientEnvironments: desktop.clientEnvironments,
|
|
recordResult: (result) => {
|
|
progress.hopperOutcome = result;
|
|
},
|
|
});
|
|
// 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);
|
|
}
|
|
assertSetupActive(options.signal);
|
|
const clientFailure = await configureDetectedClients({
|
|
host,
|
|
detectedClients: selectedClients,
|
|
providerEnvironment,
|
|
clientEnvironments: desktop.clientEnvironments,
|
|
command: host.registrationCommand,
|
|
clients,
|
|
appliedActions,
|
|
signal: options.signal,
|
|
...(options.onProgress === undefined
|
|
? {}
|
|
: { onProgress: options.onProgress }),
|
|
});
|
|
assertSetupActive(options.signal);
|
|
// 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,
|
|
...hopperDownload(hopperFailure),
|
|
};
|
|
if (clientFailure !== undefined)
|
|
return withHopperFailure(await fail(clientFailure));
|
|
if (planSelection.installSkill) {
|
|
const skill = await installSkillAction(
|
|
host,
|
|
options,
|
|
appliedActions,
|
|
selectedSkillClientIds,
|
|
);
|
|
progress.skill = skill;
|
|
if (!skill.ok && skill.error.completed) pending.skill = skill.error;
|
|
assertSetupActive(options.signal);
|
|
if (!skill.ok && !skill.error.completed) {
|
|
return withHopperFailure(await failSkill(skill.error));
|
|
}
|
|
}
|
|
const doctor = summarizeDoctor(await host.doctor(readinessScope));
|
|
const remediation = combineRemediation(
|
|
desktopFailure,
|
|
hopperFailure?.remediation ??
|
|
desktop.readinessRemediation(doctor.healthy) ??
|
|
finalSetupRemediation(
|
|
host.platform,
|
|
appliedActions.includes("installed_hopper"),
|
|
doctor.healthy,
|
|
// Offer installation only where the explicit gate accepts it.
|
|
hopperPath === undefined &&
|
|
planDiscovery.hopperInstall.supported !== false,
|
|
),
|
|
hopperWarning,
|
|
manualRemediation,
|
|
);
|
|
return {
|
|
status: remediation === undefined ? "ready" : "needs_human",
|
|
plannedActions,
|
|
appliedActions,
|
|
clients,
|
|
doctor,
|
|
clientStates,
|
|
...(remediation === undefined ? {} : { remediation }),
|
|
...(hopperFailure === undefined
|
|
? {}
|
|
: { code: hopperFailure.code, ...hopperDownload(hopperFailure) }),
|
|
};
|
|
};
|
|
|
|
const hopperDownload = (
|
|
failure: SetupHopperInstallFailure,
|
|
): Pick<SetupResult, "download"> =>
|
|
failure.download === undefined ? {} : { download: failure.download };
|
|
|
|
const installHopperAction = async (input: {
|
|
readonly host: SetupHost;
|
|
readonly options: SetupOptions;
|
|
readonly appliedActions: string[];
|
|
readonly selectedClients: readonly SetupClient[];
|
|
readonly providerEnvironment: SetupProviderEnvironment;
|
|
readonly clientEnvironments: Readonly<
|
|
Record<string, SetupProviderEnvironment>
|
|
>;
|
|
readonly recordResult: (result: SetupHopperInstallResult) => void;
|
|
}) => {
|
|
const label = "Hopper deep-analysis provider";
|
|
emitProgress(input.options, {
|
|
actionId: "install_hopper",
|
|
label,
|
|
state: "started",
|
|
});
|
|
const installed = await input.host.installHopper(
|
|
input.options.installHopper,
|
|
input.options.signal,
|
|
);
|
|
input.recordResult(installed);
|
|
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, clientEnvironments: input.clientEnvironments },
|
|
input.host.registrationCommand,
|
|
),
|
|
};
|
|
};
|
|
|
|
const installSkillAction = async (
|
|
host: SetupHost,
|
|
options: SetupOptions,
|
|
appliedActions: string[],
|
|
skillClientIds: readonly string[],
|
|
): Promise<SkillInstallResult> => {
|
|
const label = "REA reverse-engineering skill";
|
|
emitProgress(options, {
|
|
actionId: "install_skill",
|
|
label,
|
|
state: "started",
|
|
});
|
|
const skill = await host.installSkill(skillClientIds);
|
|
if (!skill.ok) {
|
|
if (skill.error.completed) appliedActions.push("installed_skill");
|
|
emitProgress(options, {
|
|
actionId: "install_skill",
|
|
label,
|
|
state: skill.error.completed ? "warning" : "failed",
|
|
detail: skill.error.message,
|
|
});
|
|
return skill;
|
|
}
|
|
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 skill;
|
|
};
|
|
|
|
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[];
|
|
},
|
|
desktopBuild: boolean,
|
|
): DoctorScope => ({
|
|
clients: selection.selectedClients.map(({ name }) => name),
|
|
providers: [
|
|
...new Set([
|
|
...(selection.installHopper ? ["hopper"] : (requested?.providers ?? [])),
|
|
...(desktopBuild ? [DESKTOP_BUILD_PROVIDER_ID] : []),
|
|
]),
|
|
],
|
|
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,
|
|
},
|
|
}),
|
|
});
|