diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 4be14a3a9..a0b8c4743 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -9,6 +9,7 @@ import { } from "@server/client/daemon-client"; import { connectionFromListen, + hostHasConnection, normalizeStoredHostProfile, upsertHostConnectionInProfiles, registryHasConnection, @@ -486,6 +487,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { export class HostRuntimeController { private host: HostProfile; private deps: HostRuntimeControllerDeps; + private onReconcileServerId: ((oldId: string, newId: string) => void) | null; private connectionMachineState: HostRuntimeConnectionMachineState; private snapshot: HostRuntimeSnapshot; private listeners = new Set<() => void>(); @@ -503,9 +505,14 @@ export class HostRuntimeController { private probeRequestVersion = 0; private probeCycleInFlight: Promise | null = null; - constructor(input: { host: HostProfile; deps?: HostRuntimeControllerDeps }) { + constructor(input: { + host: HostProfile; + deps?: HostRuntimeControllerDeps; + onReconcileServerId?: (oldId: string, newId: string) => void; + }) { this.host = input.host; this.deps = input.deps ?? createDefaultDeps(); + this.onReconcileServerId = input.onReconcileServerId ?? null; this.connectionMachineState = { tag: "booting", }; @@ -846,10 +853,14 @@ export class HostRuntimeController { connection, }); if (serverId !== this.host.serverId) { - await client.close().catch(() => undefined); - throw new Error( - `Connection resolved to ${serverId}, expected ${this.host.serverId}.`, - ); + if (isPlaceholderServerId(this.host.serverId) && this.onReconcileServerId) { + this.onReconcileServerId(this.host.serverId, serverId); + } else { + await client.close().catch(() => undefined); + throw new Error( + `Connection resolved to ${serverId}, expected ${this.host.serverId}.`, + ); + } } connectedClient = client; shouldCloseClient = true; @@ -1132,6 +1143,14 @@ export class HostRuntimeController { } } + adoptReconciledServerId(newServerId: string): void { + this.host = { ...this.host, serverId: newServerId }; + this.snapshot = { ...this.snapshot, serverId: newServerId }; + for (const listener of this.listeners) { + listener(); + } + } + private resolveClientId(): Promise { if (!this.clientIdPromise) { this.clientIdPromise = this.deps.getClientId().then((value) => { @@ -1144,10 +1163,32 @@ export class HostRuntimeController { } const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; -const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767"; +const LOCALHOST_FALLBACK_ENDPOINT = "localhost:6767"; const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500; const E2E_STORAGE_KEY = "@paseo:e2e"; +function readConfiguredLocalDaemonOverride(): string | null { + const value = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim(); + return value && value.length > 0 ? value : null; +} + +function placeholderServerIdForEndpoint(endpoint: string): string { + return `local:${normalizeHostPort(endpoint)}`; +} + +function isPlaceholderServerId(serverId: string): boolean { + return serverId.startsWith("local:"); +} + +function rekeyMap(map: Map, oldKey: string, newKey: string): void { + const value = map.get(oldKey); + if (value === undefined) { + return; + } + map.delete(oldKey); + map.set(newKey, value); +} + export class HostRuntimeStore { private controllers = new Map(); private serverListeners = new Map void>>(); @@ -1193,6 +1234,11 @@ export class HostRuntimeStore { private async runBoot(): Promise { await this.loadFromStorage(); + const override = readConfiguredLocalDaemonOverride(); + if (override) { + this.prunePlaceholderHostsForOtherEndpoints(override); + } + let isE2E: string | null = null; try { isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY); @@ -1203,8 +1249,14 @@ export class HostRuntimeStore { return; } - if (!shouldUseDesktopDaemon()) { - await this.bootstrapLocalhost(); + if (shouldUseDesktopDaemon()) { + return; + } + + if (override) { + this.bootstrapConfiguredOverride(override); + } else { + await this.bootstrapDefaultLocalhost(); } } @@ -1229,30 +1281,103 @@ export class HostRuntimeStore { } } - private async bootstrapLocalhost(): Promise { - try { - const connection = connectionFromListen(DEFAULT_LOCALHOST_ENDPOINT); - if (!connection || registryHasConnection(this.hosts, connection)) { - return; - } - - try { - const { client, serverId, hostname } = await connectToDaemon(connection, { - timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS, - }); - - await this.upsertHostConnection({ - serverId, - label: hostname ?? undefined, - connection, - existingClient: client, - }); - } catch { - // Best-effort bootstrap only - } - } catch (error) { - console.warn("[HostRuntime] Failed to bootstrap host connections", error); + private async bootstrapDefaultLocalhost(): Promise { + const connection = connectionFromListen(LOCALHOST_FALLBACK_ENDPOINT); + if (!connection || registryHasConnection(this.hosts, connection)) { + return; } + + try { + const { client, serverId, hostname } = await connectToDaemon(connection, { + timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS, + }); + + await this.upsertHostConnection({ + serverId, + label: hostname ?? undefined, + connection, + existingClient: client, + }); + } catch (error) { + console.warn("[HostRuntime] bootstrap probe failed", { + endpoint: LOCALHOST_FALLBACK_ENDPOINT, + error, + }); + } + } + + private bootstrapConfiguredOverride(endpoint: string): void { + const connection = connectionFromListen(endpoint); + if (!connection) { + return; + } + const placeholderId = placeholderServerIdForEndpoint(endpoint); + const staleHosts = this.hosts.filter( + (host) => host.serverId !== placeholderId && hostHasConnection(host, connection), + ); + for (const staleHost of staleHosts) { + void this.removeConnection(staleHost.serverId, connection.id); + } + if (registryHasConnection(this.hosts, connection)) { + return; + } + void this.upsertHostConnection({ + serverId: placeholderId, + connection, + }); + } + + private prunePlaceholderHostsForOtherEndpoints(currentEndpoint: string): void { + const expectedServerId = placeholderServerIdForEndpoint(currentEndpoint); + const remaining = this.hosts.filter((host) => { + if (!isPlaceholderServerId(host.serverId)) { + return true; + } + return host.serverId === expectedServerId; + }); + if (remaining.length === this.hosts.length) { + return; + } + this.setHostsAndSync(remaining); + void this.persistHosts(); + } + + reconcileServerId(oldServerId: string, newServerId: string): void { + if (oldServerId === newServerId) { + return; + } + const controller = this.controllers.get(oldServerId); + if (!controller) { + return; + } + if (this.controllers.has(newServerId)) { + return; + } + + rekeyMap(this.controllers, oldServerId, newServerId); + controller.adoptReconciledServerId(newServerId); + + rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId); + rekeyMap(this.agentDirectoryBootstrapInFlight, oldServerId, newServerId); + + const listeners = this.serverListeners.get(oldServerId); + if (listeners) { + this.serverListeners.delete(oldServerId); + const merged = this.serverListeners.get(newServerId) ?? new Set<() => void>(); + for (const listener of listeners) { + merged.add(listener); + } + this.serverListeners.set(newServerId, merged); + } + + this.hosts = this.hosts.map((host) => + host.serverId === oldServerId + ? { ...host, serverId: newServerId, updatedAt: new Date().toISOString() } + : host, + ); + this.emitHostList(); + this.emit(newServerId); + void this.persistHosts(); } async upsertDirectConnection(input: { @@ -1480,6 +1605,7 @@ export class HostRuntimeStore { const controller = new HostRuntimeController({ host, deps: this.deps, + onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), }); this.controllers.set(host.serverId, controller); this.lastConnectionStatusByServer.set(