fix(desktop): retain primary gateway registry identity after reload
This commit is contained in:
@@ -674,6 +674,10 @@ export function useGatewayBoot({
|
||||
// (connectionId, profile) keep-set so two sources exposing the same
|
||||
// profile name (every source has a 'default') can't collide.
|
||||
configureGatewayRegistry({
|
||||
// The primary socket has no secondary entry to carry registry identity.
|
||||
// Electron's published active descriptor is authoritative after boot;
|
||||
// a true legacy primary has no connectionId and remains unqualified.
|
||||
activeConnectionId: () => $connection.get()?.connectionId ?? null,
|
||||
// Every dispose path in the registry (live-work pruner AND the
|
||||
// refcount-0 request leases) spares a socket a mounted tile, the
|
||||
// primary thread or a just-created session's owner hold is bound to
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$connection,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
_resetSessionOwnerHintsForTests,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
setActiveSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setConnection,
|
||||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions
|
||||
@@ -336,13 +338,19 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across
|
||||
mintedRuntimeId = RUNTIME_ID
|
||||
mintedStoredId = STORED_ID
|
||||
clearSingleFlightSessionResumeState()
|
||||
configureGatewayRegistry({ onEvent: vi.fn() })
|
||||
// Wired exactly as useGatewayBoot: the published active descriptor carries
|
||||
// a registry-backed primary's source identity across a renderer reload.
|
||||
configureGatewayRegistry({
|
||||
activeConnectionId: () => $connection.get()?.connectionId ?? null,
|
||||
onEvent: vi.fn()
|
||||
})
|
||||
closeSecondaryGateways()
|
||||
installDesktop()
|
||||
setSessions([])
|
||||
setMessages([])
|
||||
setActiveSessionId(null)
|
||||
setSelectedStoredSessionId(null)
|
||||
setConnection(null)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
$newChatProfile.set(null)
|
||||
@@ -357,6 +365,7 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across
|
||||
setSessions([])
|
||||
setActiveSessionId(null)
|
||||
setSelectedStoredSessionId(null)
|
||||
setConnection(null)
|
||||
$newChatProfile.set(null)
|
||||
$newChatRoute.set(null)
|
||||
$newChatConnectionId.set(null)
|
||||
@@ -425,6 +434,50 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across
|
||||
|
||||
const calls = (socket: MockGateway) => socket.request.mock.calls.map(call => call[0] as string)
|
||||
|
||||
it('dials homelab::omar when boot published homelab on the active primary gateway', async () => {
|
||||
const primary = makePrimary()
|
||||
|
||||
setPrimaryGateway(primary as never, 'default')
|
||||
expect(activeGateway()).toBe(primary as never)
|
||||
// A true legacy primary has no published registry identity.
|
||||
expect(activeGatewayConnectionId()).toBeNull()
|
||||
|
||||
// Cold boot publishes the resolved primary descriptor before profile-rail
|
||||
// interaction. No registry secondary has been opened in this scenario.
|
||||
setConnection({ connectionId: SOURCE_ID, mode: 'remote', profile: 'default' } as never)
|
||||
expect(activeGatewayConnectionId()).toBe(SOURCE_ID)
|
||||
|
||||
selectProfile('omar')
|
||||
|
||||
const desktop = window.hermesDesktop!
|
||||
|
||||
await waitFor(() =>
|
||||
expect(desktop.getConnectionFor).toHaveBeenCalledWith({ connectionId: SOURCE_ID, profile: 'omar' })
|
||||
)
|
||||
expect(desktop.getConnection).not.toHaveBeenCalledWith('omar')
|
||||
expect($newChatConnectionId.get()).toBe(SOURCE_ID)
|
||||
})
|
||||
|
||||
it('keeps the legacy profile door when boot published `local` on the active primary gateway', async () => {
|
||||
// The published identity survives the reload, but a pick on the explicit
|
||||
// local source is still a legacy profile pick (per-profile remote
|
||||
// overrides resolve through getConnection), so the draft's owner is the
|
||||
// v1 profile socket, never the registry entry local::omar.
|
||||
const primary = makePrimary()
|
||||
|
||||
setPrimaryGateway(primary as never, 'default')
|
||||
setConnection({ connectionId: 'local', mode: 'local', profile: 'default' } as never)
|
||||
expect(activeGatewayConnectionId()).toBe('local')
|
||||
|
||||
selectProfile('omar')
|
||||
|
||||
const desktop = window.hermesDesktop!
|
||||
|
||||
await waitFor(() => expect(desktop.getConnection).toHaveBeenCalledWith('omar'))
|
||||
expect(desktop.getConnectionFor).not.toHaveBeenCalledWith({ connectionId: 'local', profile: 'omar' })
|
||||
expect($newChatConnectionId.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('session.create and both prompt.submit calls ride the SAME conn:homelab::omar socket', async () => {
|
||||
const { handle, omarSocket, primary } = await bootProfileRailOmar()
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ const normKey = (profile: string | null | undefined): string => (profile ?? '').
|
||||
const isOpen = (gateway: HermesGateway | null): boolean => gateway?.connectionState === 'open'
|
||||
|
||||
interface RegistryConfig {
|
||||
/** Electron's published descriptor is authoritative for a primary gateway's
|
||||
* registry identity. Kept as a getter so gateway.ts does not own or duplicate
|
||||
* the connection store. */
|
||||
activeConnectionId?: () => null | string
|
||||
onEvent: (event: GatewayEvent) => void
|
||||
onActiveConnectionInvalidated?: (fallbackProfile: string, activationEpoch: number) => void
|
||||
onActiveConnectionChanged?: (connection: HermesConnection) => void
|
||||
@@ -304,15 +308,17 @@ export function activeGateway(): HermesGateway | null {
|
||||
|
||||
/**
|
||||
* The registry connection serving the gateway the user is currently looking
|
||||
* at — null for the local/legacy primary path and for profile-keyed (local)
|
||||
* secondaries. Event consumers pair this with the event's own `connectionId`
|
||||
* tag so "from the active profile" really means "from the active SOURCE":
|
||||
* at. A registry-backed primary takes its identity from the published primary
|
||||
* connection, falling back to Electron's active descriptor until that is set;
|
||||
* a true legacy primary (no resolved connectionId) and profile-keyed local
|
||||
* secondaries remain null. Event consumers pair this with the event's own
|
||||
* `connectionId` tag so "from the active profile" really means "from the active SOURCE":
|
||||
* two connected gateways can both expose a 'default' profile, and a bare
|
||||
* profile comparison attributed gateway B's 'default' activity to gateway A.
|
||||
*/
|
||||
export function activeGatewayConnectionId(): null | string {
|
||||
if (g.activeKey === g.primaryProfile) {
|
||||
return g.primaryConnectionId
|
||||
return g.primaryConnectionId ?? (g.config?.activeConnectionId?.()?.trim() || null)
|
||||
}
|
||||
|
||||
return g.secondaries.get(g.activeKey)?.connectionId ?? null
|
||||
|
||||
Reference in New Issue
Block a user