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:
@@ -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])
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
Vendored
+9
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user