fix(desktop): preserve registry route identity
This commit is contained in:
@@ -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<EffectiveSshRoute> {
|
||||
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) => {
|
||||
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string>()
|
||||
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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
configureGatewayRegistry,
|
||||
disposeSecondariesForConnection,
|
||||
ensureGatewayForProfile,
|
||||
isActivePrimary,
|
||||
gatewayActivationEpoch,
|
||||
isActivePrimary,
|
||||
pruneSecondaryGateways,
|
||||
reconnectSecondaryGateways,
|
||||
reportPrimaryGatewayState,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<T>(
|
||||
/**
|
||||
* 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<T>(
|
||||
connectionId: null | string,
|
||||
@@ -526,7 +526,7 @@ export async function requestGatewayForAgent<T>(
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
const key = normKey(profile)
|
||||
const scope = backendScopeKey(connectionId, key)
|
||||
const scope = registryBackendScopeKey(connectionId, key)
|
||||
|
||||
if (scope === key) {
|
||||
return requestGatewayForProfile<T>(key, method, params)
|
||||
@@ -581,13 +581,13 @@ export async function openGatewayForProfile(profile: string): Promise<void> {
|
||||
|
||||
// ── 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<void> {
|
||||
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<void> {
|
||||
const scope = backendScopeKey(connectionId, profile)
|
||||
export async function ensureGatewayForAgent(connectionId: null | string, profile: string): Promise<boolean> {
|
||||
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
|
||||
|
||||
@@ -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<unknown>({ 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}`)
|
||||
|
||||
@@ -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<void> {
|
||||
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.
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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<string, string>()
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}::`
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export {
|
||||
backendScopeKey,
|
||||
backendScopePrefix,
|
||||
LOCAL_CONNECTION_ID
|
||||
LOCAL_CONNECTION_ID,
|
||||
registryBackendScopeKey
|
||||
} from './backend-scope'
|
||||
export {
|
||||
BILLING_REFUSAL_POLICY,
|
||||
|
||||
Reference in New Issue
Block a user