diff --git a/apps/desktop/electron/backend-resolution.test.ts b/apps/desktop/electron/backend-resolution.test.ts index 4dd8e14c92..078365b0c6 100644 --- a/apps/desktop/electron/backend-resolution.test.ts +++ b/apps/desktop/electron/backend-resolution.test.ts @@ -1,141 +1,62 @@ -import assert from 'node:assert/strict' +import { describe, expect, it, vi } from 'vitest' -import { test } from 'vitest' +import { createInstalledRuntimeGate } from './backend-resolution' -import { - type BackendResolutionInput, - isIgnoreExisting, - postBootstrapResolveOptions, - selectBackendRung -} from './backend-resolution' +const ROOT = '/home/u/.hermes/hermes-agent' +const active = { label: 'active runtime' } -function discovered(overrides: Partial = {}): BackendResolutionInput { - return { - ignoreExisting: false, - justInstalled: false, - bootstrapRepairRequested: false, - hasExplicitRoot: false, - hasUnpackagedSource: false, - activeRuntimeUsable: false, - hasExplicitCommand: false, - hasPathHermes: false, - hasSystemPython: false, - ...overrides - } +function gate(env: NodeJS.ProcessEnv) { + const log = vi.fn() + + return { log, runtime: createInstalledRuntimeGate(env, log) } } -test('ignore-existing is exactly the string 1', () => { - assert.equal(isIgnoreExisting('1'), true) - assert.equal(isIgnoreExisting('true'), false) - assert.equal(isIgnoreExisting('0'), false) - assert.equal(isIgnoreExisting(''), false) - assert.equal(isIgnoreExisting(undefined), false) -}) +describe('installed runtime gate', () => { + it('uses the installed runtime without the flag', async () => { + const { runtime } = gate({}) + const probe = vi.fn(async () => active) -test('ignore-existing skips the active runtime and system python, not only hermes on PATH', () => { - // The flag used to wrap only the PATH probe. A usable managed install at - // ACTIVE_HERMES_ROOT, or a system Python that can import hermes_cli, still - // started a local serve. - assert.equal( - selectBackendRung( - discovered({ - ignoreExisting: true, - activeRuntimeUsable: true, - hasPathHermes: true, - hasSystemPython: true - }) - ), - 'onboarding' - ) -}) - -test('explicit root and an unpackaged source checkout stay above the ignore-existing gate', () => { - const ignored = { - ignoreExisting: true, - activeRuntimeUsable: true, - hasPathHermes: true, - hasSystemPython: true - } - - assert.equal(selectBackendRung(discovered({ ...ignored, hasExplicitRoot: true })), 'explicit-root') - assert.equal(selectBackendRung(discovered({ ...ignored, hasUnpackagedSource: true })), 'unpackaged-source') -}) - -test('an explicit hermes command is not a discovered runtime', () => { - assert.equal( - selectBackendRung( - discovered({ - ignoreExisting: true, - activeRuntimeUsable: true, - hasExplicitCommand: true, - hasPathHermes: true, - hasSystemPython: true - }) - ), - 'explicit-command' - ) -}) - -test('the post-bootstrap re-resolve uses the runtime just installed instead of repeating onboarding', () => { - const ignored = discovered({ - ignoreExisting: true, - activeRuntimeUsable: true, - hasPathHermes: true, - hasSystemPython: true + await expect(runtime.resolve(ROOT, probe)).resolves.toBe(active) + expect(probe).toHaveBeenCalledOnce() }) - assert.equal(selectBackendRung(ignored), 'onboarding') - assert.equal( - selectBackendRung({ - ...ignored, - justInstalled: postBootstrapResolveOptions().justInstalled - }), - 'active' - ) -}) + it('skips the installed runtime without probing it when HERMES_DESKTOP_IGNORE_EXISTING=1', async () => { + const { log, runtime } = gate({ HERMES_DESKTOP_IGNORE_EXISTING: '1' }) + const probe = vi.fn(async () => active) -test('a just-installed runtime that is not usable does not fall back to a discovered runtime or repeat onboarding', () => { - assert.equal( - selectBackendRung( - discovered({ - ignoreExisting: true, - justInstalled: true, - activeRuntimeUsable: false, - hasPathHermes: true, - hasSystemPython: true - }) - ), - 'installed-unusable' - ) -}) + await expect(runtime.resolve(ROOT, probe)).resolves.toBeNull() + expect(probe).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledWith(expect.stringContaining(ROOT)) + }) -test('without the flag a usable active runtime still wins, and repair still bypasses it', () => { - assert.equal( - selectBackendRung( - discovered({ - activeRuntimeUsable: true, - hasPathHermes: true, - hasSystemPython: true - }) - ), - 'active' - ) - assert.equal( - selectBackendRung( - discovered({ - bootstrapRepairRequested: true, - activeRuntimeUsable: true, - hasPathHermes: true - }) - ), - 'path' - ) - assert.equal( - selectBackendRung( - discovered({ - hasSystemPython: true - }) - ), - 'system-python' - ) + it('honours only the value the CLI exports', async () => { + for (const value of ['0', 'true', '']) { + const { runtime } = gate({ HERMES_DESKTOP_IGNORE_EXISTING: value }) + + await expect(runtime.resolve(ROOT, async () => active)).resolves.toBe(active) + } + }) + + it('uses the runtime this launch installed so the post-install re-resolve does not reinstall', async () => { + const { runtime } = gate({ HERMES_DESKTOP_IGNORE_EXISTING: '1' }) + const probe = vi.fn(async () => active) + // main.ts: resolveHermesBackend's ACTIVE_HERMES_ROOT rung, else bootstrap-needed. + const resolveHermesBackend = async () => (await runtime.resolve(ROOT, probe)) ?? 'bootstrap-needed' + + await expect(resolveHermesBackend()).resolves.toBe('bootstrap-needed') + expect(probe).not.toHaveBeenCalled() + // main.ts ensureRuntime: the re-resolve after a successful bootstrap. + await expect(runtime.afterInstall(resolveHermesBackend)).resolves.toBe(active) + // Later resolves (pool backends, TUI resume) keep the new runtime too. + await expect(resolveHermesBackend()).resolves.toBe(active) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('reads the flag at resolve time', async () => { + const env: NodeJS.ProcessEnv = {} + const { runtime } = gate(env) + + env.HERMES_DESKTOP_IGNORE_EXISTING = '1' + await expect(runtime.resolve(ROOT, async () => active)).resolves.toBeNull() + }) }) diff --git a/apps/desktop/electron/backend-resolution.ts b/apps/desktop/electron/backend-resolution.ts index 82100c8e73..0e198774ce 100644 --- a/apps/desktop/electron/backend-resolution.ts +++ b/apps/desktop/electron/backend-resolution.ts @@ -1,143 +1,35 @@ /** - * Precedence for Desktop backend resolution below the two explicit source - * rungs (HERMES_DESKTOP_HERMES_ROOT and an unpackaged checkout). + * HERMES_DESKTOP_IGNORE_EXISTING=1 (`hermes desktop --ignore-existing`) keeps + * Desktop off the installed runtime at ACTIVE_HERMES_ROOT. Backend resolution + * then falls through to bootstrap-needed, which shows the first-run choice + * (connect a remote, or install) instead of starting a local serve. * - * HERMES_DESKTOP_IGNORE_EXISTING=1 skips discovered rungs — the managed - * install, `hermes` on PATH, and system Python — so no local serve starts - * and the caller falls through to connect/onboarding. It does not skip an - * explicit HERMES_DESKTOP_HERMES command. + * The bundled payload, HERMES_DESKTOP_HERMES_ROOT, an unpackaged checkout and + * HERMES_DESKTOP_HERMES resolve before this rung and are not affected. * - * The post-bootstrap re-resolve must pass `justInstalled`. Otherwise the - * flag would skip the runtime this process just installed and the installer - * would see onboarding again and repeat. + * A runtime this launch installed is always used. Skipping it would send the + * post-install re-resolve (and every later resolve) back to the installer. */ -export type BackendRung = - | 'explicit-root' - | 'unpackaged-source' - | 'active' - | 'explicit-command' - | 'path' - | 'system-python' - | 'onboarding' - | 'installed-unusable' +export function createInstalledRuntimeGate(env: NodeJS.ProcessEnv, log: (message: string) => void) { + let installedThisLaunch = false -export interface BackendResolutionInput { - ignoreExisting: boolean - justInstalled: boolean - bootstrapRepairRequested: boolean - hasExplicitRoot: boolean - hasUnpackagedSource: boolean - activeRuntimeUsable: boolean - hasExplicitCommand: boolean - hasPathHermes: boolean - hasSystemPython: boolean -} + return { + /** The ACTIVE_HERMES_ROOT rung: probe the installed runtime unless the flag skips it. */ + async resolve(root: string, probe: () => Promise): Promise { + if (env.HERMES_DESKTOP_IGNORE_EXISTING === '1' && !installedThisLaunch) { + log(`[bootstrap] HERMES_DESKTOP_IGNORE_EXISTING=1; skipping the installed runtime at ${root}`) -const NO_CANDIDATES: BackendResolutionInput = { - ignoreExisting: false, - justInstalled: false, - bootstrapRepairRequested: false, - hasExplicitRoot: false, - hasUnpackagedSource: false, - activeRuntimeUsable: false, - hasExplicitCommand: false, - hasPathHermes: false, - hasSystemPython: false -} + return null + } -export const INSTALLED_RUNTIME_UNUSABLE = - 'Hermes install finished, but the new runtime could not be resolved. Desktop will not start the installer again.' + return probe() + }, -export function isIgnoreExisting(value: string | undefined): boolean { - return value === '1' -} + /** Re-resolve after a successful bootstrap; the new runtime is exempt from the flag. */ + afterInstall(resolveBackend: () => Promise): Promise { + installedThisLaunch = true -/** Options the re-resolve after a successful bootstrap must pass. */ -export function postBootstrapResolveOptions(): { justInstalled: true } { - return { justInstalled: true } -} - -export function selectBackendRung(input: BackendResolutionInput): BackendRung { - if (input.hasExplicitRoot) { - return 'explicit-root' + return resolveBackend() + } } - - if (input.hasUnpackagedSource) { - return 'unpackaged-source' - } - - const skipDiscovered = input.ignoreExisting && !input.justInstalled - - if (input.activeRuntimeUsable && !input.bootstrapRepairRequested && !skipDiscovered) { - return 'active' - } - - if (input.hasExplicitCommand) { - return 'explicit-command' - } - - if (!input.ignoreExisting && input.hasPathHermes) { - return 'path' - } - - if (!input.ignoreExisting && input.hasSystemPython) { - return 'system-python' - } - - // The installer already ran. Onboarding here would start it again; PATH and - // system Python are the discovered runtimes the flag promised to skip. - if (input.ignoreExisting && input.justInstalled) { - return 'installed-unusable' - } - - return 'onboarding' -} - -export function shouldProbeActiveRuntime(input: { - ignoreExisting: boolean - justInstalled: boolean - bootstrapRepairRequested: boolean -}): boolean { - return ( - selectBackendRung({ - ...NO_CANDIDATES, - ...input, - activeRuntimeUsable: true - }) === 'active' - ) -} - -export function shouldProbeDiscoveredPath(input: { ignoreExisting: boolean }): boolean { - return ( - selectBackendRung({ - ...NO_CANDIDATES, - ignoreExisting: input.ignoreExisting, - hasPathHermes: true - }) === 'path' - ) -} - -export function shouldProbeSystemPython(input: { ignoreExisting: boolean }): boolean { - return ( - selectBackendRung({ - ...NO_CANDIDATES, - ignoreExisting: input.ignoreExisting, - hasSystemPython: true - }) === 'system-python' - ) -} - -export function unresolvedDiscoveredRuntime(input: { - ignoreExisting: boolean - justInstalled: boolean -}): 'onboarding' | 'installed-unusable' { - const rung = selectBackendRung({ - ...NO_CANDIDATES, - ignoreExisting: input.ignoreExisting, - justInstalled: input.justInstalled, - hasPathHermes: true, - hasSystemPython: true - }) - - return rung === 'installed-unusable' ? 'installed-unusable' : 'onboarding' } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e7983b7205..3a3fe6e2bc 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -79,6 +79,7 @@ import { canImportHermesCli, PROBE_TIMEOUT_MS, shouldTrustHermesOverride, verify import { waitForDashboardPortAnnouncement } from './backend-ready' import { recycleOwnedBackend } from './backend-recycle' import { isPidAliveWindows, waitForBackendRelease } from './backend-release-gate' +import { createInstalledRuntimeGate } from './backend-resolution' import { createBackendServeSupportResolver } from './backend-serve-support' import { isHostKeyChangedBootFailure, @@ -4752,6 +4753,8 @@ function writeDefaultProjectDir(dir) { } } +const installedRuntimeGate = createInstalledRuntimeGate(process.env, rememberLog) + async function resolveHermesBackend(backendArgs: string[]): Promise { const payload = bundledPayload(process.resourcesPath) @@ -4869,9 +4872,10 @@ async function resolveHermesBackend(backendArgs: string[]): Promise + resolveSourceInstallationBackend(ACTIVE_HERMES_ROOT, backendArgs, { hermesHome: HERMES_HOME }) + ) const activeRuntime: ActiveRuntimeState = activeRuntimeState(activeBackend) @@ -5064,7 +5068,10 @@ async function ensureRuntime( rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.') // Resolve the newly published launcher after the installer completes. - return ensureRuntime(await resolveHermesBackend(backend.args), assertStillOwned) + return ensureRuntime( + await installedRuntimeGate.afterInstall(() => resolveHermesBackend(backend.args)), + assertStillOwned + ) } throw new Error(`Unexpected bootstrap backend: ${backend.kind}`)