feat(desktop): route backends by (connection, profile) — registry-scoped pool keys

Phase 2 of the multi-connection campaign (#86679 shipped the registry).
The Electron backend pool can now serve agents from ANY registered
connection concurrently, keyed by composite (connection, profile) scopes.

- connection-registry.ts: backendScopeKey(connectionId, profile) — the
  single home of the composite-key rule. Local/empty connection ids keep
  the BARE profile key, so every legacy pool entry, reaper log line, and
  touch call is byte-identical for single-source users; non-local
  connections get `conn:<id>::<profile>`, which cannot collide with a
  plain profile name. backendScopePrefix() matches the keys a connection
  owns (teardown on remove).
- main.ts ensureRegistryBackend(connectionId, profile): resolves a backend
  against the v2 registry. local kind delegates to ensureBackend()
  untouched; remote/cloud dial the entry's own URL/auth (descriptor carries
  profile + connectionId + sharedRemote for per-request ?profile= scoping);
  ssh bootstraps a tunnel scoped to the composite key, with the served
  dashboard token persisted back onto the REGISTRY entry (not v1
  connection.json). Pool entries reuse the existing LRU/idle-reaper/touch
  lifecycle.
- hermes:connections:remove now stops every pooled backend + ssh scope the
  removed connection owns.
- New IPC hermes:connection:for + preload getConnectionFor + renderer
  types (connectionId/sharedRemote on HermesConnection). No renderer
  behavior change yet — the multi-source roster/socket switchover is PR 3.

Tests: +2 backendScopeKey contract cases (28 total in the registry suite);
electron + settings projects 1357 passed; both tsc configs and eslint clean.
This commit is contained in:
Teknium
2026-08-15 01:51:01 -07:00
parent 467ec9208a
commit 883b57cbd8
5 changed files with 225 additions and 1 deletions
@@ -12,6 +12,8 @@ import { test } from 'vitest'
import type { ConnectionRegistry } from './connection-registry'
import {
agentHandle,
backendScopeKey,
backendScopePrefix,
connectionIdForLabel,
labelKey,
labelSlug,
@@ -70,6 +72,26 @@ test('uniqueLabel counts up (never "X 2 2") and clamps long candidates', () => {
assert.ok(uniqueLabel(long, [uniqueLabel(long, [])]).length <= 64)
})
// --- backendScopeKey (composite pool keys) ---
test('backendScopeKey: local/empty connection keeps the bare profile key', () => {
assert.equal(backendScopeKey(null, 'research'), 'research')
assert.equal(backendScopeKey('', 'research'), 'research')
assert.equal(backendScopeKey(LOCAL_CONNECTION_ID, 'research'), 'research')
assert.equal(backendScopeKey('local', ''), 'default')
assert.equal(backendScopeKey(undefined, undefined), 'default')
})
test('backendScopeKey: non-local connections get an unambiguous composite', () => {
assert.equal(backendScopeKey('homelab', 'research'), 'conn:homelab::research')
assert.equal(backendScopeKey('homelab', ''), 'conn:homelab::default')
// Composite keys can never collide with a plain profile name, and the
// prefix helper matches exactly the keys the connection owns.
assert.ok(backendScopeKey('homelab', 'research').startsWith(backendScopePrefix('homelab')))
assert.ok(!backendScopeKey('homelab-2', 'research').startsWith(backendScopePrefix('homelab')))
assert.ok(!'research'.startsWith(backendScopePrefix('homelab')))
})
// --- normalizeConnectionInput ---
test('save rejects the reserved "local" id on non-local kinds', () => {
@@ -134,6 +134,31 @@ export function agentHandle(profile: string, connectionLabel: string, duplicated
return duplicated ? `${name}-${labelSlug(connectionLabel)}` : name
}
/**
* Pool key for a backend serving (connection, profile). The local/primary
* connection keeps the BARE profile key so every legacy pool entry, reaper
* log line, and touch call stays byte-identical for single-source users;
* non-local connections get an unambiguous composite (`conn:<id>::<profile>`)
* that cannot collide with a plain profile name (colons are invalid in
* profile names). The single home of the composite-key rule — Electron pool
* keying and any renderer socket registry must both derive keys here.
*/
export function backendScopeKey(connectionId: null | string | undefined, profile: null | string | undefined): string {
const profileKey = String(profile ?? '').trim() || 'default'
const connection = String(connectionId ?? '').trim()
if (!connection || connection === LOCAL_CONNECTION_ID) {
return profileKey
}
return `conn:${connection}::${profileKey}`
}
/** All pool keys owned by a connection share this prefix (used to stop them on remove). */
export function backendScopePrefix(connectionId: string): string {
return `conn:${String(connectionId).trim()}::`
}
/** Mint a registry-unique id from a label (slug, then -2/-3… suffixes). */
export function connectionIdForLabel(label: string, taken: Iterable<string>): string {
const used = new Set([...taken])
+167 -1
View File
@@ -83,6 +83,8 @@ import {
tokenPreview
} from './connection-config'
import {
backendScopeKey,
backendScopePrefix,
mergeConnectionInput,
migrateV1ToRegistry,
normalizeConnectionInput,
@@ -8603,6 +8605,22 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc
function persistSshConnectionToken(profile, source, token) {
try {
// Registry-scoped ssh backend (source "registry:<connectionId>"): the
// served token belongs on the registry entry, not v1 connection.json.
if (typeof source === 'string' && source.startsWith('registry:')) {
const id = source.slice('registry:'.length)
const registry = readDesktopConnectionsRegistry()
const entry = registry.connections.find(c => c.id === id)
if (entry && entry.kind === 'ssh') {
writeDesktopConnectionsRegistry(
upsertConnection(registry, { ...entry, token: encryptDesktopSecret(token) })
)
}
return
}
const config = readDesktopConnectionConfig()
const encrypted = encryptDesktopSecret(token)
@@ -9171,6 +9189,142 @@ async function ensureBackend(profile) {
return entry.connectionPromise
}
// ── Registry-scoped backends (multi-connection, PR 2 of the campaign) ──────
// Resolve a backend for (connectionId, profile) against the v2 registry.
// The LOCAL connection routes through ensureBackend() untouched, so every
// single-source path stays byte-identical; non-local connections pool under
// the composite key from backendScopeKey() and reuse the same pool entry
// lifecycle (LRU, idle reaper, touch) as per-profile local backends.
async function ensureRegistryBackend(connectionId, profile) {
const registry = readDesktopConnectionsRegistry()
const id = String(connectionId || '').trim() || registry.primary
const source = registry.connections.find(c => c.id === id)
if (!source) {
throw new Error(`No connection with id "${id}".`)
}
if (source.kind === 'local') {
return ensureBackend(profile)
}
const key = backendScopeKey(id, profile)
const existing = backendPool.get(key)
if (existing) {
existing.lastActiveAt = Date.now()
return existing.connectionPromise
}
evictLruPoolBackends(POOL_MAX_BACKENDS - 1)
const entry = {
process: null,
port: null,
token: null,
connectionPromise: null,
lastActiveAt: Date.now(),
remoteBaseUrl: null
}
entry.connectionPromise = connectRegistryBackend(source, profile, key, entry).catch(error => {
if (backendPool.get(key) === entry) {
backendPool.delete(key)
}
throw error
})
backendPool.set(key, entry)
startPoolIdleReaper()
return entry.connectionPromise
}
// Dial a non-local registry connection for one profile. Never spawns a local
// child (entry.process stays null — stopPoolBackend/evict already tolerate
// that shape from remote per-profile overrides).
async function connectRegistryBackend(source, profile, key, poolEntry) {
const profileKey = String(profile ?? '').trim() || 'default'
if (source.kind === 'ssh') {
// The composite key doubles as the ssh scope so each (connection, profile)
// pair owns its own tunnel + remote dashboard; the profile that re-homes
// the REMOTE process is the entry's remoteProfile or the requested one —
// never the composite string.
const sshConfig = normalizeSshConfig({
mode: 'ssh',
host: source.host,
user: source.user,
port: source.port,
keyPath: source.keyPath,
remoteHermesPath: source.remoteHermesPath,
remoteProfile: source.remoteProfile || (profileKey === 'default' ? '' : profileKey)
})
if (!sshConfig) {
throw new Error(`SSH connection "${source.label}" has no host configured.`)
}
const connection = await bootstrapSshConnection(
key,
sshConfig,
decryptDesktopSecret(source.token),
`registry:${source.id}`
)
poolEntry.remoteBaseUrl = connection.baseUrl
return { ...connection, profile: profileKey, connectionId: source.id, logs: hermesLog.slice(-80), ...getWindowState() }
}
// remote / cloud: one gateway host serves every profile of that source,
// scoped per request — the descriptor carries the profile + connectionId so
// renderer-side WS minting and REST scoping target the right agent.
const token = source.authMode === 'oauth' ? null : decryptDesktopSecret(source.token)
const connection = await buildRemoteConnection(
source.url,
normAuthMode(source.authMode),
token,
`registry:${source.id}`,
undefined,
source.kind === 'cloud' ? 'cloud' : 'url'
)
await waitForHermes(connection.baseUrl, connection.token, undefined, connection.authMode)
poolEntry.remoteBaseUrl = connection.baseUrl
return {
...connection,
profile: profileKey,
connectionId: source.id,
// One host, many profiles: REST paths must carry ?profile= (same contract
// as the global-remote shared-primary route).
sharedRemote: true,
logs: hermesLog.slice(-80),
...getWindowState()
}
}
// Stop every pooled backend and ssh scope owned by a registry connection —
// called when the connection is removed from the registry.
function stopRegistryConnectionBackends(connectionId) {
const prefix = backendScopePrefix(connectionId)
for (const key of [...backendPool.keys()]) {
if (String(key).startsWith(prefix)) {
stopPoolBackend(key)
}
}
for (const scope of [...sshConnections.keys()]) {
if (String(scope).startsWith(prefix)) {
void teardownSshConnection(scope)
}
}
}
// Mark a pool profile as recently used so the idle reaper spares it. The
// renderer calls this when it opens a profile's chat WS and periodically while
// streaming, since the main process can't see the direct renderer↔backend WS.
@@ -11099,6 +11253,14 @@ function createWindow() {
}
ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile))
// Registry-scoped variant: resolve a backend for (connectionId, profile).
// connectionId '' / 'local' / the registry primary all behave sensibly; the
// local kind delegates to ensureBackend so legacy behavior is untouched.
ipcMain.handle('hermes:connection:for', async (_event, payload) => {
const { connectionId, profile } = payload && typeof payload === 'object' ? (payload as any) : ({} as any)
return ensureRegistryBackend(connectionId, profile)
})
// Reconnect-after-wake recovery. A REMOTE primary backend has no child process,
// so the 'exit'/'error' handlers that would clear a dead connection promise never
// fire — once the remote becomes unreachable across a sleep/wake the renderer
@@ -11650,8 +11812,12 @@ ipcMain.handle('hermes:connections:save', async (_event, payload) => {
return { ok: true, connection: saved, registry: sanitizeConnectionsRegistry() }
})
ipcMain.handle('hermes:connections:remove', async (_event, id) => {
const registry = removeConnection(readDesktopConnectionsRegistry(), String(id || ''))
const key = String(id || '')
const registry = removeConnection(readDesktopConnectionsRegistry(), key)
writeDesktopConnectionsRegistry(registry)
// Tear down anything the removed connection still had running: pooled
// backends under its composite keys and any ssh tunnel scopes it owned.
stopRegistryConnectionBackends(key)
return { ok: true, registry: sanitizeConnectionsRegistry(registry) }
})
+2
View File
@@ -2,6 +2,8 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron'
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
// Registry-scoped backend resolution: { connectionId, profile } → descriptor.
getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload),
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
+9
View File
@@ -18,6 +18,9 @@ declare global {
// the window's backend; pass a named profile to lazily spawn/reuse that
// profile's backend from the pool.
getConnection: (profile?: string | null) => Promise<HermesConnection>
// Registry-scoped backend resolution: dial (connectionId, profile). An
// empty/local connectionId delegates to the legacy getConnection path.
getConnectionFor?: (payload: { connectionId?: null | string; profile?: null | string }) => Promise<HermesConnection>
// Reconnect-after-wake recovery: liveness-probe the cached PRIMARY backend
// and drop it if a remote one has gone unreachable, so the next
// getConnection() rebuilds a reachable descriptor instead of the renderer
@@ -560,10 +563,16 @@ export interface HermesConnection {
// Set for pool (non-primary) backends so the renderer knows which profile a
// connection belongs to.
profile?: string
// The registry connection this descriptor was resolved through (absent on
// legacy v1/primary paths). Set by getConnectionFor.
connectionId?: string
// True only when `profile` is a request scope on the shared primary backend.
// A pooled backend also carries `profile`, so presence alone cannot identify
// the shared-primary routing case.
sharedPrimary?: boolean
// True when `profile` is a request scope on a SHARED registry remote/cloud
// backend (one host, many profiles) — the registry analogue of sharedPrimary.
sharedRemote?: boolean
windowButtonPosition: { x: number; y: number } | null
}