hermes-agent/apps/desktop/electron/primary-backend-startup.ts
cat-that's-fat 7dd2b2dc77 feat(desktop): add "Connect to existing Hermes" option to first-run onboarding
Adds a first-run Desktop choice between installing Hermes locally and
connecting to an existing remote Hermes gateway. The choice appears after
backend resolution but before ensureRuntime(), so selecting remote cannot
accidentally trigger local bootstrap.

New modules:
- first-run-setup-gate: concurrent first-run decision gate and reset semantics
- primary-backend-startup: Electron-free orchestration seam (saved remote
  resolution, gate decision, remote re-resolution, local continuation)
- primary-connection-rehome: prevents dual-owner race where both cold boot()
  and renderer softSwitch() could connect simultaneously
- first-run-remote-form: extracted remote form with stale-result guards

Reuses existing connection-config IPC, encrypted token storage, OAuth
session partition, and primary backend resolution.

Fixes #38602
Fixes #36970
2026-07-24 12:55:06 -05:00

66 lines
2.3 KiB
TypeScript

import type { FirstRunSetupDecision } from './first-run-setup-gate'
export interface PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection> {
connectRemote: (remote: Remote) => Promise<Connection>
ensureLocalRuntime: (backend: Backend) => Promise<RuntimeBackend>
prepareLocalBackend: () => Backend | Promise<Backend>
resolveRemote: () => Promise<Remote | null>
waitForDecision: (backend: Backend) => Promise<FirstRunSetupDecision>
waitForLocalStart: () => Promise<unknown>
}
export type PrimaryBackendStartupResult<RuntimeBackend, Connection> =
| { kind: 'local'; backend: RuntimeBackend }
| { kind: 'remote'; connection: Connection }
export class FirstRunSetupResetError extends Error {
readonly firstRunSetupReset = true
constructor() {
super('First-run setup was reset before a choice completed.')
this.name = 'FirstRunSetupResetError'
}
}
// Owns the production startHermes path up to the local process spawn. Keeping
// the full ordering here makes the first-run remote boundary executable in a
// test: an already-saved remote wins immediately; otherwise update exclusion
// and local backend resolution happen before the setup gate, and a remote Apply
// re-resolves persisted config without ever entering ensureRuntime/bootstrap.
export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote, Connection>({
connectRemote,
ensureLocalRuntime,
prepareLocalBackend,
resolveRemote,
waitForDecision,
waitForLocalStart
}: PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection>): Promise<
PrimaryBackendStartupResult<RuntimeBackend, Connection>
> {
const savedRemote = await resolveRemote()
if (savedRemote) {
return { kind: 'remote', connection: await connectRemote(savedRemote) }
}
await waitForLocalStart()
const backend = await prepareLocalBackend()
const decision = await waitForDecision(backend)
if (decision === 'remote-applied') {
const appliedRemote = await resolveRemote()
if (!appliedRemote) {
throw new Error('First-run remote setup completed without a saved remote backend.')
}
return { kind: 'remote', connection: await connectRemote(appliedRemote) }
}
if (decision === 'reset') {
throw new FirstRunSetupResetError()
}
return { kind: 'local', backend: await ensureLocalRuntime(backend) }
}