diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 0b7de9d562..c97d49f54a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -215,15 +215,13 @@ import { parentWatchdogEnv } from './parent-process-identity' import { - buildOpaqueProfileRoutes, buildRegistryProfileRoutes, - type EffectiveSshRoute, localRouteFallbackProfiles, - type ProfileRouteConfig, registryGatewayWsUrl, undialedSshRouteSeeds } from './plugin-profile-routes' import { selectPoolEvictions } from './pool-eviction' +import { poolTouchKeys } from './pool-touch-scope' import { createKeepAwake } from './power-save' import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup' import { rehomePrimaryConnection } from './primary-connection-rehome' @@ -8566,39 +8564,6 @@ function effectiveSshConfigFingerprint(sshConfig) { return crypto.createHash('sha256').update(output).digest('hex') } -function effectiveSshRouteForPlugin(config: ProfileRouteConfig): Promise { - const ssh = - process.platform === 'win32' - ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') - : 'ssh' - - const args = ['-G'] - - if (config.sshPort) { - args.push('-p', String(config.sshPort)) - } - - args.push('--', config.sshUser ? `${config.sshUser}@${config.sshHost}` : config.sshHost) - - return new Promise((resolve, reject) => { - execFile(ssh, args, { encoding: 'utf8', timeout: 10_000, windowsHide: true }, (error, stdout) => { - if (error) { - reject(new Error('Could not resolve SSH route for profile.')) - - return - } - - const resolved = parseSshGOutput(stdout) - - resolve({ - hostname: resolved.hostname || config.sshHost, - port: resolved.port || config.sshPort || 22, - user: resolved.user || config.sshUser - }) - }) - }) -} - async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) { const scope = sshScopeKey(profile) const effectiveConfigFingerprint = effectiveSshConfigFingerprint(sshConfig) @@ -9540,16 +9505,14 @@ async function stopRegistryConnectionBackends(connectionId) { // 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. function touchPoolBackend(profile) { - const key = profile && String(profile).trim() ? String(profile).trim() : null + for (const key of poolTouchKeys(profile)) { + const entry = backendPool.get(key) - if (!key) { - return - } + if (entry) { + entry.lastActiveAt = Date.now() - const entry = backendPool.get(key) - - if (entry) { - entry.lastActiveAt = Date.now() + return + } } } @@ -12059,20 +12022,7 @@ ipcMain.handle('hermes:plugin-profile-routes', async (_event, rawProfileNames) = ] } - const config = readDesktopConnectionConfig() - const globalConfig = (await sanitizeDesktopConnectionConfig(config, null)) as ProfileRouteConfig - const localProfiles = agents.filter(agent => agent.connectionId === 'local').map(agent => agent.profile) - - const legacyRoutes = await buildOpaqueProfileRoutes({ - getProfileConfig: async profile => (await sanitizeDesktopConnectionConfig(config, profile)) as ProfileRouteConfig, - globalConfig, - installationId: desktopInstallationId, - primaryProfile: primaryProfileKey(), - profileNames: localProfiles, - resolveSsh: effectiveSshRouteForPlugin - }) - - return buildRegistryProfileRoutes({ agents, legacyRoutes, sources: registry.connections }) + return buildRegistryProfileRoutes({ agents, sources: registry.connections }) }) ipcMain.handle('hermes:ssh-config:hosts', async () => ({ hosts: collectSshConfigHosts() })) ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => { diff --git a/apps/desktop/electron/plugin-profile-routes.test.ts b/apps/desktop/electron/plugin-profile-routes.test.ts index 1f40e9d847..d8deedc23a 100644 --- a/apps/desktop/electron/plugin-profile-routes.test.ts +++ b/apps/desktop/electron/plugin-profile-routes.test.ts @@ -235,7 +235,7 @@ describe('buildRegistryProfileRoutes', () => { expect(new Set(routes.map(route => `${route.connectionId}/${route.profile}`))).toHaveLength(2) }) - it('preserves legacy v1 targetProfile correction for routes reached through local', () => { + it('keeps the registry local source genuinely local when legacy v1 routing is remote', () => { const routes = buildRegistryProfileRoutes({ agents: [{ connectionId: 'local', profile: 'barry' }], legacyRoutes: [ @@ -245,7 +245,7 @@ describe('buildRegistryProfileRoutes', () => { }) expect(routes).toEqual([ - { connectionId: 'local', mode: 'remote', profile: 'barry', targetProfile: 'default' } + { connectionId: 'local', mode: 'local', profile: 'barry', targetProfile: 'barry' } ]) }) diff --git a/apps/desktop/electron/plugin-profile-routes.ts b/apps/desktop/electron/plugin-profile-routes.ts index 3f1a1d0468..1d2ae81122 100644 --- a/apps/desktop/electron/plugin-profile-routes.ts +++ b/apps/desktop/electron/plugin-profile-routes.ts @@ -244,17 +244,14 @@ export async function buildOpaqueProfileRoutes({ /** * Project the union registry roster into the narrow plugin descriptor. Registry * ids and profile names are routing identities; endpoint/auth/source fields are - * deliberately discarded here. The local source keeps the v1 resolver's mode - * and targetProfile semantics because getConnectionFor(local, profile) delegates - * to that compatibility path. + * deliberately discarded here. A registry source of kind `local` always means + * the actual local runtime, independently of legacy v1 global/profile routing. */ export function buildRegistryProfileRoutes({ agents, - legacyRoutes = [], sources }: BuildRegistryProfileRoutesOptions): OpaqueProfileRoute[] { const sourceById = new Map(sources.map(source => [source.id, source])) - const legacyByProfile = new Map(legacyRoutes.map(route => [route.profile, route])) const seen = new Set() const routes: OpaqueProfileRoute[] = [] @@ -270,13 +267,11 @@ export function buildRegistryProfileRoutes({ seen.add(key) if (source.kind === 'local') { - const legacy = legacyByProfile.get(profile) - routes.push({ connectionId: source.id, - mode: legacy?.mode ?? 'local', + mode: 'local', profile, - targetProfile: legacy?.targetProfile ?? profile + targetProfile: profile }) continue diff --git a/apps/desktop/electron/pool-touch-scope.test.ts b/apps/desktop/electron/pool-touch-scope.test.ts new file mode 100644 index 0000000000..f21b13071c --- /dev/null +++ b/apps/desktop/electron/pool-touch-scope.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' + +import { poolTouchKeys } from './pool-touch-scope' + +describe('poolTouchKeys', () => { + it('falls back from an explicit local registry scope to its delegated bare profile', () => { + expect(poolTouchKeys('conn:local::research')).toEqual(['conn:local::research', 'research']) + }) + + it('does not alias non-local registry scopes', () => { + expect(poolTouchKeys('conn:homelab::research')).toEqual(['conn:homelab::research']) + }) +}) diff --git a/apps/desktop/electron/pool-touch-scope.ts b/apps/desktop/electron/pool-touch-scope.ts new file mode 100644 index 0000000000..3a6f943e2c --- /dev/null +++ b/apps/desktop/electron/pool-touch-scope.ts @@ -0,0 +1,19 @@ +export function poolTouchKeys(scope: unknown): string[] { + const key = String(scope ?? '').trim() + + if (!key) { + return [] + } + + const localPrefix = 'conn:local::' + + if (key.startsWith(localPrefix)) { + const delegatedProfile = key.slice(localPrefix.length) + + if (delegatedProfile) { + return [key, delegatedProfile] + } + } + + return [key] +} diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index aacc0b0f73..e6b4e26f3d 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -19,8 +19,8 @@ import { configureGatewayRegistry, disposeSecondariesForConnection, ensureGatewayForProfile, - isActivePrimary, gatewayActivationEpoch, + isActivePrimary, pruneSecondaryGateways, reconnectSecondaryGateways, reportPrimaryGatewayState, diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index ea655d961b..1759bee3e6 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -1,5 +1,5 @@ import type { BillingBlock } from '@hermes/shared' -import { backendScopeKey } from '@hermes/shared' +import { registryBackendScopeKey } from '@hermes/shared' import type { HermesSkin } from '@hermes/shared/skin' import type { QueryClient } from '@tanstack/react-query' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' @@ -340,12 +340,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // registered connection exposes a 'default' profile, so a bare profile // comparison attributes gateway B's 'default' events to gateway A's // 'default'. Compare the composite (connectionId, profile) scope with - // backendScopeKey — untagged (local/primary) events keep the legacy + // registryBackendScopeKey — untagged primary events keep the legacy // bare-profile behavior byte-identical. const fromActiveSource = (): boolean => (!event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())) && - backendScopeKey(event.connectionId ?? null, event.profile ?? null) === - backendScopeKey(activeGatewayConnectionId(), event.profile ?? null) + registryBackendScopeKey(event.connectionId ?? null, event.profile ?? null) === + registryBackendScopeKey(activeGatewayConnectionId(), event.profile ?? null) const occurredAt = typeof payload?.timestamp === 'number' && Number.isFinite(payload.timestamp) diff --git a/apps/desktop/src/store/gateway-agent-scope.test.ts b/apps/desktop/src/store/gateway-agent-scope.test.ts index 9100b68c5f..69d8f16903 100644 --- a/apps/desktop/src/store/gateway-agent-scope.test.ts +++ b/apps/desktop/src/store/gateway-agent-scope.test.ts @@ -42,6 +42,7 @@ const { closeSecondaryGateways, configureGatewayRegistry, ensureGatewayForAgent, + ensureGatewayForProfile, isActivePrimary, pruneSecondaryGateways, setPrimaryGateway @@ -104,6 +105,25 @@ describe('registry-agent scope eviction (activeGateway must never silently hit t expect(gatewayMocks.connect).toHaveBeenCalledWith(agentConn.wsUrl) }) + it('keeps the primary active when a fresh registry activation cannot resolve', async () => { + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + await ensureGatewayForProfile('default') + const publishedPrimary = $gateway.get() + installDesktop({ + getConnection: vi.fn(async () => agentConn), + getConnectionFor: vi.fn(async () => { + throw new Error('source unreachable') + }) + }) + + await expect(ensureGatewayForAgent('offline', 'research')).resolves.toBe(false) + + expect(isActivePrimary()).toBe(true) + expect(activeGateway()).toBe(primary) + expect($gateway.get()).toBe(publishedPrimary) + }) + it('closeSecondaryGateways re-points the active key at the primary instead of dangling', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') diff --git a/apps/desktop/src/store/gateway-profile-request.test.ts b/apps/desktop/src/store/gateway-profile-request.test.ts index 2a59d30406..196c76e14d 100644 --- a/apps/desktop/src/store/gateway-profile-request.test.ts +++ b/apps/desktop/src/store/gateway-profile-request.test.ts @@ -39,7 +39,8 @@ vi.mock('@/hermes', () => ({ constructor() { secondaryGateways.push(this) } - } + }, + setApiRequestConnection: vi.fn() })) vi.mock('@/store/session', () => ({ setGatewayState: vi.fn() })) vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() })) @@ -232,15 +233,30 @@ describe('requestGatewayForAgent', () => { expect($gateway.get()).toBe(primary) }) - it('falls back to the legacy profile path for the local registry connection', async () => { + it('routes an explicit local registry descriptor through getConnectionFor', async () => { const primary = makePrimary() - const getConnection = vi.fn(async (profile: null | string) => ({ port: 5151, profile, token: 'legacy-token' })) - const getConnectionFor = vi.fn() + + const getConnection = vi.fn(async (profile: null | string) => ({ + mode: 'remote', + profile, + wsUrl: 'wss://legacy-remote.invalid/api/ws?token=legacy' + })) + + const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({ + connectionId, + mode: 'local', + port: 5151, + profile + })) setPrimaryGateway(primary as never, 'default') ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection, getConnectionFor, + getGatewayWsUrlFor: vi.fn(async ({ connectionId, profile }) => ({ + ok: true as const, + wsUrl: `ws://${connectionId}/${profile}` + })), touchBackend: vi.fn(async () => undefined) } await ensureGatewayForProfile('default') @@ -249,8 +265,8 @@ describe('requestGatewayForAgent', () => { method: 'profiles.list', params: {} }) - expect(getConnection).toHaveBeenCalledWith('worker') - expect(getConnectionFor).not.toHaveBeenCalled() + expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'local', profile: 'worker' }) + expect(getConnection).not.toHaveBeenCalled() expect($gateway.get()).toBe(primary) }) diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index f54467c0b0..0aa8bcf53e 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -1,8 +1,8 @@ -import { backendScopeKey, type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@hermes/shared' +import { type ConnectionState, type GatewayEvent, registryBackendScopeKey, resolveGatewayWsUrl } from '@hermes/shared' import { atom } from 'nanostores' -import { HermesGateway, setApiRequestConnection } from '@/hermes' import type { HermesConnection } from '@/global' +import { HermesGateway, setApiRequestConnection } from '@/hermes' import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' import { markNativeNotifyBaseline } from '@/store/notify-baseline' import { setConnection, setGatewayState } from '@/store/session' @@ -31,7 +31,7 @@ interface RegistryConfig { // ── Secondary (pool) backends ────────────────────────────────────────────── interface Secondary { - /** Scope key from backendScopeKey(connectionId, profile). */ + /** Scope key from registryBackendScopeKey(connectionId, profile). */ scope: string profile: string /** Registry connection serving this socket; null = the local/legacy path. */ @@ -355,7 +355,7 @@ function isMissingConnectionError(error: unknown): boolean { function createSecondary(profile: string, connectionId: null | string = null): Secondary { const gateway = new HermesGateway() - const scope = backendScopeKey(connectionId, profile) + const scope = registryBackendScopeKey(connectionId, profile) const entry: Secondary = { scope, @@ -516,8 +516,8 @@ export async function requestGatewayForProfile( /** * Send a gateway RPC through one registry source without activating it. The * composite (connectionId, profile) pool key prevents same-named agents on two - * sources from sharing a socket. Local/empty ids deliberately retain the v1 - * profile resolver, including shared-primary request scoping. + * sources from sharing a socket. Only null/empty ids retain the v1 profile + * resolver; explicit `local` is a registry source and must use getConnectionFor. */ export async function requestGatewayForAgent( connectionId: null | string, @@ -526,7 +526,7 @@ export async function requestGatewayForAgent( params: Record = {} ): Promise { const key = normKey(profile) - const scope = backendScopeKey(connectionId, key) + const scope = registryBackendScopeKey(connectionId, key) if (scope === key) { return requestGatewayForProfile(key, method, params) @@ -581,13 +581,13 @@ export async function openGatewayForProfile(profile: string): Promise { // ── Connection-scoped agents (multi-source roster) ───────────────────────── // The (connectionId, profile) analogues of the profile functions above. A -// null/'local' connectionId falls straight through to the profile path, so -// callers can pass roster rows verbatim without special-casing the local -// source. Feature-detected: without the Electron getConnectionFor door these +// null connectionId falls straight through to the profile path. An explicit +// `local` id remains registry-scoped so it cannot inherit legacy remote v1 +// routing. Feature-detected: without the Electron getConnectionFor door these // throw, and roster surfaces disable non-local rows instead. export async function openGatewayForAgent(connectionId: null | string, profile: string): Promise { - const scope = backendScopeKey(connectionId, profile) + const scope = registryBackendScopeKey(connectionId, profile) if (scope === normKey(profile)) { return openGatewayForProfile(profile) @@ -606,11 +606,13 @@ export async function openGatewayForAgent(connectionId: null | string, profile: } } -export async function ensureGatewayForAgent(connectionId: null | string, profile: string): Promise { - const scope = backendScopeKey(connectionId, profile) +export async function ensureGatewayForAgent(connectionId: null | string, profile: string): Promise { + const scope = registryBackendScopeKey(connectionId, profile) if (scope === normKey(profile)) { - return ensureGatewayForProfile(profile) + await ensureGatewayForProfile(profile) + + return true } if (!window.hermesDesktop?.getConnectionFor) { @@ -641,14 +643,17 @@ export async function ensureGatewayForAgent(connectionId: null | string, profile // A source edit/remove may dispose this entry while its dial is still in // flight. Only the still-registered, still-owned activation may publish. - if ( + const activated = entry.wantOpen && g.secondaries.get(scope) === entry && - applyActive(scope, activationEpoch) && - entry.connection - ) { + Boolean(entry.connection) && + applyActive(scope, activationEpoch) + + if (activated && entry.connection) { publishActiveConnection(entry.connection) } + + return activated } // Make `profile` the active gateway, lazily opening its socket if needed. The @@ -774,7 +779,7 @@ function restoreActiveToPrimaryIfEvicted(): void { // Close + evict secondaries whose scope is neither active nor in `keep` // (scopes with a running / needs-input session). Bounds cost to live work. // `keep` carries PROFILE names for local/legacy entries and composite -// backendScopeKey(connectionId, profile) scopes for registry-sourced live +// registryBackendScopeKey(connectionId, profile) scopes for registry-sourced live // work. A registry-scoped entry matches ONLY on its composite key: every // source exposes a 'default' profile, so matching a non-local entry on the // bare profile name kept gateway B's 'default' socket alive off gateway A's diff --git a/apps/desktop/src/store/profile-agent-activation.test.ts b/apps/desktop/src/store/profile-agent-activation.test.ts index c19b7278da..9e41056292 100644 --- a/apps/desktop/src/store/profile-agent-activation.test.ts +++ b/apps/desktop/src/store/profile-agent-activation.test.ts @@ -14,7 +14,7 @@ import type { HermesConnection } from '@/global' // without it, two rapid activations could complete out of order and the // EARLIER setActive() landed last. -const ensureGatewayForAgent = vi.fn(async (_connectionId: null | string, _profile: string) => undefined) +const ensureGatewayForAgent = vi.fn(async (_connectionId: null | string, _profile: string) => true) const ensureGatewayForProfile = vi.fn(async (_profile: string) => undefined) const openGatewayForProfile = vi.fn(async (_profile: string) => undefined) const $gateway = atom({ id: 'live-socket' }) @@ -98,7 +98,17 @@ describe('ensureGatewayAgent → $connection / $activeGatewayProfile sync', () = expect($connection.get()?.mode).toBe('local') }) - it('falls through to the profile path for a local/null connectionId', async () => { + it('does not republish a registry identity invalidated during activation', async () => { + ensureGatewayForAgent.mockResolvedValueOnce(false) + + await ensureGatewayAgent('removed-source', 'research') + + expect($activeGatewayProfile.get()).toBe('default') + expect($connection.get()?.mode).toBe('local') + expect(getConnectionFor).not.toHaveBeenCalled() + }) + + it('falls through to the profile path for a null connectionId', async () => { getConnection.mockResolvedValue(agentConn({ mode: 'local', profile: 'research' })) await ensureGatewayAgent(null, 'research') @@ -107,6 +117,16 @@ describe('ensureGatewayAgent → $connection / $activeGatewayProfile sync', () = expect(ensureGatewayForAgent).not.toHaveBeenCalled() expect(getConnectionFor).not.toHaveBeenCalled() }) + + it('keeps an explicit local registry id on the registry-aware path', async () => { + getConnectionFor.mockResolvedValue(localConn({ profile: 'research' })) + + await ensureGatewayAgent('local', 'research') + + expect(ensureGatewayForAgent).toHaveBeenCalledWith('local', 'research') + expect(ensureGatewayForProfile).not.toHaveBeenCalled() + expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'local', profile: 'research' }) + }) }) describe('ensureGatewayAgent shares the gatewaySwitch mutex with profile switches', () => { @@ -120,6 +140,8 @@ describe('ensureGatewayAgent shares the gatewaySwitch mutex with profile switche }) ensureGatewayForAgent.mockImplementation(async (_connectionId, profile) => { order.push(`agent:${profile}`) + + return true }) getConnection.mockResolvedValue(localConn({ profile: 'worker' })) getConnectionFor.mockResolvedValue(agentConn()) @@ -151,6 +173,8 @@ describe('ensureGatewayAgent shares the gatewaySwitch mutex with profile switche ensureGatewayForAgent.mockImplementation(async (_connectionId, profile) => { order.push(`agent:${profile}`) await agentGate.promise + + return true }) ensureGatewayForProfile.mockImplementation(async (profile: string) => { order.push(`profile:${profile}`) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 73ab65e5f5..a844598be4 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -1,4 +1,3 @@ -import { backendScopeKey } from '@hermes/shared' import { atom, computed } from 'nanostores' import { getProfiles, setApiRequestProfile, STARTUP_REQUEST_TIMEOUT_MS } from '@/hermes' @@ -337,12 +336,13 @@ async function syncConnectionToActiveAgent(connectionId: string, profile: string // - Activations share the gatewaySwitch mutex with profile switches, so a // rapid agent↔profile (or agent↔agent) interleave can't finish out of // order and leave the EARLIER setActive() as the last write. -// A local/null connectionId falls through to the profile path verbatim. +// Only a null connectionId falls through to the legacy profile path. Explicit +// `local` is a registry identity and must use the genuinely-local route. export async function ensureGatewayAgent(connectionId: null | string, profile: string): Promise { const target = normalizeProfileKey(profile) const connection = (connectionId ?? '').trim() || null - if (!connection || backendScopeKey(connection, target) === target) { + if (!connection) { return ensureGatewayProfile(target) } @@ -353,7 +353,12 @@ export async function ensureGatewayAgent(connectionId: null | string, profile: s $gatewaySwapTarget.set(target) gatewaySwitch = (async () => { - await ensureGatewayForAgent(connection, target) + const activated = await ensureGatewayForAgent(connection, target) + + if (!activated) { + return + } + $activeGatewayProfile.set(target) // The active backend just changed; resync $connection so remote-aware // paths (image.attach_bytes vs image.attach, /api/fs/*, /api/media) follow. diff --git a/apps/desktop/src/store/session-states-scopes.test.ts b/apps/desktop/src/store/session-states-scopes.test.ts index b5b96474c7..437895f435 100644 --- a/apps/desktop/src/store/session-states-scopes.test.ts +++ b/apps/desktop/src/store/session-states-scopes.test.ts @@ -36,6 +36,13 @@ describe('liveSessionScopes', () => { expect(liveSessionScopes()).toEqual(new Set(['conn:homelab::default'])) }) + it('keeps an explicit local registry session on its composite scope', () => { + recordSessionEventScope({ connectionId: 'local', profile: 'default', session_id: 'rt-local' }) + publishSessionState('rt-local', state({ busy: true })) + + expect(liveSessionScopes()).toEqual(new Set(['conn:local::default'])) + }) + it('includes needs-input sessions and drops settled ones', () => { recordSessionEventScope({ connectionId: 'homelab', profile: 'default', session_id: 'rt-1' }) publishSessionState('rt-1', state({ busy: false, needsInput: true })) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 028d85c910..912ceb32a7 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -16,7 +16,7 @@ * itself here as the delegate so tile UI stays dependency-light. */ -import { backendScopeKey } from '@hermes/shared' +import { registryBackendScopeKey } from '@hermes/shared' import { atom, computed } from 'nanostores' import type { ClientSessionState } from '@/app/types' @@ -70,7 +70,7 @@ const sessionScopeByRuntimeId = new Map() export function recordSessionEventScope(event: { connectionId?: string; profile?: string; session_id?: string }): void { if (event.session_id && event.connectionId) { - sessionScopeByRuntimeId.set(event.session_id, backendScopeKey(event.connectionId, event.profile)) + sessionScopeByRuntimeId.set(event.session_id, registryBackendScopeKey(event.connectionId, event.profile)) } } diff --git a/apps/shared/src/backend-scope.ts b/apps/shared/src/backend-scope.ts index 0da9a73069..5a2be28974 100644 --- a/apps/shared/src/backend-scope.ts +++ b/apps/shared/src/backend-scope.ts @@ -23,6 +23,18 @@ export function backendScopeKey(connectionId: null | string | undefined, profile return `conn:${connection}::${profileKey}` } +/** Scope a registry route without collapsing its explicit `local` source id. + * Null/empty ids still identify the legacy profile-only route. */ +export function registryBackendScopeKey( + connectionId: null | string | undefined, + profile: null | string | undefined +): string { + const profileKey = String(profile ?? '').trim() || 'default' + const connection = String(connectionId ?? '').trim() + + return connection ? `conn:${connection}::${profileKey}` : profileKey +} + /** All pool keys owned by a connection share this prefix (teardown on remove). */ export function backendScopePrefix(connectionId: string): string { return `conn:${String(connectionId).trim()}::` diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 933120b215..1cb4220e0d 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -1,7 +1,8 @@ export { backendScopeKey, backendScopePrefix, - LOCAL_CONNECTION_ID + LOCAL_CONNECTION_ID, + registryBackendScopeKey } from './backend-scope' export { BILLING_REFUSAL_POLICY,