mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(desktop): every gateway stays connected; picking one is instant, with no switch prompt
Desktop treated registered gateways as one-at-a-time: picking another gateway ran a "switch" (barrier, session-list wipe, a "Switch to This device? This opens a fresh session on this computer..." confirm), and a gateway that was unreachable when Desktop started never showed up in the All profiles session list after it came back. - store/gateway-persistence: every registered gateway's home socket is dialed at launch, pinned against the idle pruner (retainGatewayForRelay now takes includeLocal so This device is kept too when a remote is primary), redialed every 30 s while down, and the session list is re-pulled when one connects. - selectConnection is a live activation like a profile pick: the target is opened first (a dead gateway fails there and touches nothing), then the old runtime is unbound and a fresh draft started synchronously right before the target is published (#93937 invariant kept). No barrier, no list wipe, no machine-context reset; an in-flight Settings apply is superseded. - This device prompts only when picking it would start the installer; the switchDevice* copy is gone and "Switch to" reads "Open". Other gateways' rail squares use the normal unselected weight instead of an "at rest" dim. - Docs: multi-connection-desktop.md describes always-connected gateways.
This commit is contained in:
1 parent
33920f4b2f
commit
13bd44e4a4
27 files changed
+537
-873
No files matched your search
@@ -98,11 +98,7 @@ export function ConnectionSwitcher({ compact = false, onConnect }: { compact?: b
|
||||
|
||||
void (async () => {
|
||||
if (connection?.kind === 'local' && connectionId !== activeConnectionId) {
|
||||
const accepted = await requestLocalDevice({
|
||||
connectionId,
|
||||
label: connection.label,
|
||||
replaceCenter: true
|
||||
})
|
||||
const accepted = await requestLocalDevice({ connectionId, label: connection.label })
|
||||
|
||||
if (!accepted) {
|
||||
return
|
||||
|
||||
@@ -23,25 +23,17 @@ async function probeLocalBackend(): Promise<boolean> {
|
||||
export interface LocalDeviceTarget {
|
||||
connectionId: string
|
||||
label: string
|
||||
profile?: string
|
||||
/** The switch replaces the center with a fresh session, even when Hermes is already installed. */
|
||||
replaceCenter?: boolean
|
||||
}
|
||||
|
||||
interface LocalDevicePrompt extends LocalDeviceTarget {
|
||||
kind: 'install' | 'switch'
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a re-home onto This device. Bootstrap-needed always confirms, and offers
|
||||
* Connect to existing inline so the click cannot fall through into runBootstrap.
|
||||
* An already-installed runtime still confirms before a fresh session replaces
|
||||
* the center. Named profiles on an installed local runtime keep the existing
|
||||
* spinner path — they are not the Home-looking misclick.
|
||||
* Gate picking This device on Hermes actually being installed here. Only a
|
||||
* click that would start the installer confirms, and it offers Connect to
|
||||
* existing inline so the click cannot fall through into runBootstrap. An
|
||||
* installed runtime is just another connected gateway: no prompt.
|
||||
*/
|
||||
export function useLocalDeviceSwitch() {
|
||||
const { t } = useI18n()
|
||||
const [prompt, setPrompt] = useState<LocalDevicePrompt | null>(null)
|
||||
const [prompt, setPrompt] = useState<LocalDeviceTarget | null>(null)
|
||||
const [connectOpen, setConnectOpen] = useState(false)
|
||||
const waiter = useRef<((accepted: boolean) => void) | null>(null)
|
||||
|
||||
@@ -71,43 +63,36 @@ export function useLocalDeviceSwitch() {
|
||||
return false
|
||||
}
|
||||
|
||||
const freshSession = target.replaceCenter === true || target.profile == null || target.profile === 'default'
|
||||
|
||||
if (!bootstrapNeeded && !freshSession) {
|
||||
if (!bootstrapNeeded) {
|
||||
return true
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
waiter.current = resolve
|
||||
setPrompt({ ...target, kind: bootstrapNeeded ? 'install' : 'switch' })
|
||||
setPrompt(target)
|
||||
})
|
||||
}
|
||||
|
||||
const fleet = t.profiles.fleet
|
||||
const install = prompt?.kind === 'install'
|
||||
|
||||
const dialog = (
|
||||
<>
|
||||
{prompt ? (
|
||||
<ConfirmDialog
|
||||
confirmLabel={install ? fleet.installDeviceConfirm : fleet.switchDeviceConfirm}
|
||||
description={install ? fleet.installDeviceDesc : fleet.switchDeviceDesc}
|
||||
confirmLabel={fleet.installDeviceConfirm}
|
||||
description={fleet.installDeviceDesc}
|
||||
dismissOnConfirm
|
||||
onClose={() => settle(false)}
|
||||
onConfirm={() => settle(true)}
|
||||
open
|
||||
secondaryAction={
|
||||
install
|
||||
? {
|
||||
label: fleet.connectExistingInstead,
|
||||
onClick: () => {
|
||||
settle(false)
|
||||
setConnectOpen(true)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={install ? fleet.installDeviceTitle : fleet.switchDeviceTitle}
|
||||
secondaryAction={{
|
||||
label: fleet.connectExistingInstead,
|
||||
onClick: () => {
|
||||
settle(false)
|
||||
setConnectOpen(true)
|
||||
}
|
||||
}}
|
||||
title={fleet.installDeviceTitle}
|
||||
/>
|
||||
) : null}
|
||||
{connectOpen ? <FirstRunRemoteSetup onBack={() => setConnectOpen(false)} /> : null}
|
||||
|
||||
@@ -163,9 +163,7 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
void requestLocalDevice({
|
||||
connectionId: agent.connectionId,
|
||||
label: agent.connectionLabel,
|
||||
profile: agent.profile,
|
||||
replaceCenter: agent.profile === 'default'
|
||||
label: agent.connectionLabel
|
||||
}).then(accepted => {
|
||||
setPendingRoute(current => (current === key ? null : current))
|
||||
|
||||
|
||||
@@ -49,14 +49,10 @@ vi.mock('@/i18n', () => ({
|
||||
installDeviceConfirm: 'Install locally',
|
||||
installDeviceDesc:
|
||||
'This will install Hermes locally, then open a fresh session on this computer. Nothing is installed until you confirm.',
|
||||
installDeviceTitle: 'Switch to This device?',
|
||||
installDeviceTitle: 'Install Hermes on This device?',
|
||||
localDevice: 'This device (local backend — installs Hermes if missing, otherwise opens a fresh session)',
|
||||
onGateway: (name: string, gateway: string) => `${name} · ${gateway}`,
|
||||
switchDeviceConfirm: 'Switch',
|
||||
switchDeviceDesc:
|
||||
'This opens a fresh session on this computer. The conversation you are in stays on the other gateway.',
|
||||
switchDeviceTitle: 'Switch to This device?',
|
||||
switchTo: (name: string, gateway: string) => `Switch to ${name} on ${gateway}`
|
||||
switchTo: (name: string, gateway: string) => `Open ${name} on ${gateway}`
|
||||
},
|
||||
importProfile: 'Import profile…',
|
||||
manageProfiles: 'Manage profiles…',
|
||||
@@ -531,7 +527,7 @@ describe('ProfileRail fleet mode', () => {
|
||||
expect(builder.getAttribute('aria-busy')).toBeNull()
|
||||
})
|
||||
|
||||
it('asks before a fresh session when This device is already installed', async () => {
|
||||
it('opens an installed This device with no prompt', async () => {
|
||||
armFleet()
|
||||
await renderFleet()
|
||||
|
||||
@@ -541,21 +537,8 @@ describe('ProfileRail fleet mode', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(selectConnection).not.toHaveBeenCalled()
|
||||
expect(await screen.findByRole('dialog', { name: 'Switch to This device?' })).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This opens a fresh session on this computer. The conversation you are in stays on the other gateway.'
|
||||
)
|
||||
).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch' }))
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(selectConnection).toHaveBeenCalledWith('local', { profile: 'default' })
|
||||
await vi.waitFor(() => expect(selectConnection).toHaveBeenCalledWith('local', { profile: 'default' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('confirms before installing and offers connect-to-existing inline', async () => {
|
||||
@@ -582,24 +565,6 @@ describe('ProfileRail fleet mode', () => {
|
||||
expect(await screen.findByText('Connect to existing Hermes')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not switch when the fresh-session cue is cancelled', async () => {
|
||||
armFleet()
|
||||
await renderFleet()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'This device (local backend — installs Hermes if missing, otherwise opens a fresh session)'
|
||||
})
|
||||
)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(selectConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the active gateway click on the plain profile path', async () => {
|
||||
armFleet()
|
||||
await renderFleet()
|
||||
|
||||
@@ -206,13 +206,12 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
|
||||
//
|
||||
// With one registered gateway this is the whole story. With several, the rail
|
||||
// becomes the FLEET rail: the active gateway's profiles stay exactly as they
|
||||
// are, and every other registered gateway follows on the same strip as an
|
||||
// at-rest group — a hairline, that gateway's kind glyph, its default home
|
||||
// square and its named squares, dimmed. Clicking an at-rest square performs
|
||||
// the same re-home the statusbar switcher does, landing on that exact
|
||||
// (gateway, profile); the workspace still lives on one gateway at a time, only
|
||||
// the picker spans the fleet. Groups keep registry order regardless of which
|
||||
// one is active, so a square never moves under the pointer that clicked it.
|
||||
// are, and every other registered gateway follows on the same strip as its own
|
||||
// group — a hairline, that gateway's kind glyph, its default home square and
|
||||
// its named squares. Every gateway is connected all the time, so clicking one
|
||||
// of those squares is a profile pick on that gateway, landing on that exact
|
||||
// (gateway, profile) with no dial. Groups keep registry order regardless of
|
||||
// which one is active, so a square never moves under the pointer that clicked it.
|
||||
export function ProfileRail() {
|
||||
const { t, locale } = useI18n()
|
||||
const p = t.profiles
|
||||
@@ -349,16 +348,14 @@ export function ProfileRail() {
|
||||
return
|
||||
}
|
||||
|
||||
// Probe spinner only. The dialog — install confirm or fresh-session cue —
|
||||
// must be on screen before selectConnection replaces the center.
|
||||
// Probe spinner only: This device prompts only when picking it would
|
||||
// start the installer.
|
||||
const key = fleetRouteKey(agent.connectionId, agent.profile)
|
||||
setPendingRoute(key)
|
||||
|
||||
void requestLocalDevice({
|
||||
connectionId: agent.connectionId,
|
||||
label: agent.connectionLabel,
|
||||
profile: agent.profile,
|
||||
replaceCenter: agent.profile === 'default'
|
||||
label: agent.connectionLabel
|
||||
}).then(accepted => {
|
||||
setPendingRoute(current => (current === key ? null : current))
|
||||
|
||||
@@ -1094,7 +1091,7 @@ interface ProfilePillProps {
|
||||
glyph: string
|
||||
label: string
|
||||
onSelect: () => void
|
||||
// Fleet at-rest: dimmed until hovered, like the at-rest squares beside it.
|
||||
// Another gateway's pill: the same unselected weight as the squares beside it.
|
||||
muted?: boolean
|
||||
pending?: boolean
|
||||
slot?: string
|
||||
@@ -1131,7 +1128,7 @@ function ProfilePill({
|
||||
className={cn(
|
||||
'bg-transparent text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
active && 'bg-(--ui-control-active-background) text-foreground',
|
||||
muted && 'opacity-40 hover:opacity-100',
|
||||
muted && 'opacity-55 hover:opacity-100',
|
||||
summary && !active && 'relative'
|
||||
)}
|
||||
data-connection-id={connectionId}
|
||||
@@ -1231,8 +1228,8 @@ function FleetRestGroup({
|
||||
: `${group.needsSignIn ? `${p.fleet.gateway(group.label)} · ${t.settings.toolsets.needsSignIn}` : p.fleet.gatewayUnreachable(group.label)}${group.error ? `\n${group.error}` : ''}`
|
||||
|
||||
const defaultKey = fleetRouteKey(group.connectionId, group.defaultAgent.profile)
|
||||
// At rest, This device is a backend switch, not Home. The house glyph stays
|
||||
// on the active gateway's default profile.
|
||||
// Off the active gateway, This device wears its device glyph, not Home. The
|
||||
// house glyph stays on the active gateway's default profile.
|
||||
const localDefault = group.kind === 'local'
|
||||
|
||||
return (
|
||||
@@ -1329,7 +1326,7 @@ function RestSquare({
|
||||
<button
|
||||
aria-busy={pending || undefined}
|
||||
aria-label={statusText ? `${label}, ${statusText}` : label}
|
||||
className="relative grid size-5 shrink-0 select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none opacity-35 transition-opacity hover:opacity-100 aria-busy:opacity-100"
|
||||
className="relative grid size-5 shrink-0 select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none opacity-55 transition-opacity hover:opacity-100 aria-busy:opacity-100"
|
||||
data-connection-id={agent.connectionId}
|
||||
data-profile={agent.profile}
|
||||
data-slot="profile-rail-rest-square"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { pathFromHermesDeepLink, resolveHermesOpenPath } from '@/lib/hermes-open
|
||||
import { storedSessionIdForNotification } from '@/lib/session-ids'
|
||||
import { announceNewSessionDraftKey } from '@/store/composer'
|
||||
import { recordAction } from '@/store/desktop-metrics'
|
||||
import { onKeptGatewayConnected } from '@/store/gateway-persistence'
|
||||
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
|
||||
import { startMcpHealthChecker, stopMcpHealthChecker } from '@/store/mcp-health'
|
||||
import {
|
||||
@@ -561,4 +562,7 @@ export function useDesktopIntegrations({
|
||||
|
||||
return onSessionsChanged(() => void refreshSessions())
|
||||
}, [refreshSessions])
|
||||
|
||||
// A gateway that connects after the list was drawn adds its rows to it.
|
||||
useEffect(() => onKeptGatewayConnected(() => void refreshSessions()), [refreshSessions])
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useEffect } from 'react'
|
||||
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { initializeConnectionsRegistry, refreshConnectionsRegistry } from '@/store/connections'
|
||||
import { keepGatewaysConnected } from '@/store/gateway-persistence'
|
||||
import { isAuxiliaryWindow, isPeerInstanceWindow } from '@/store/windows'
|
||||
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000]
|
||||
@@ -79,6 +80,9 @@ export function useConnectionsRegistry(): void {
|
||||
// auxiliary windows already have an explicit destination of their own.
|
||||
if (!boot.running && !isAuxiliaryWindow() && !isPeerInstanceWindow()) {
|
||||
void initializeConnectionsRegistry().catch(() => undefined)
|
||||
// Idempotent: a later re-boot (a connection-mode apply) keeps the other
|
||||
// gateways' sockets instead of dropping and redialing them.
|
||||
keepGatewaysConnected()
|
||||
}
|
||||
}, [boot.running])
|
||||
}
|
||||
@@ -1021,11 +1021,10 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
|
||||
it("#93937: the Sessions switcher never publishes the new source while the previous backend's runtime id is still bound", async () => {
|
||||
// Real stores end to end: real useGatewayBoot, real gateway registry, real
|
||||
// selectConnection, fake sockets. Boot on the primary VPS with a transcript
|
||||
// open (its runtime id was minted by THAT backend), then switch sources
|
||||
// through the sidebar door. Before the fix that door activated the new
|
||||
// socket first and wiped the bindings after an IPC round-trip, so the
|
||||
// renderer sat on "gateway B + runtime id from A" and B answered every
|
||||
// session RPC with "session not found".
|
||||
// open (its runtime id was minted by THAT backend), then pick another
|
||||
// gateway in the sidebar. Before #93937 that door activated the new socket
|
||||
// and unbound the old runtime after an IPC round-trip, so the renderer sat
|
||||
// on "gateway B + runtime id from A" and B answered "session not found".
|
||||
const registryConnections: DesktopConnectionsRegistry = {
|
||||
connections: [
|
||||
{ id: 'primary-vps', kind: 'remote', label: 'VPS', tokenPreview: '...t', tokenSet: true },
|
||||
@@ -1038,7 +1037,7 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
|
||||
|
||||
const desktop = fakeDesktop() as ReturnType<typeof fakeDesktop> & Record<string, unknown>
|
||||
const setLastUsed = vi.fn(async (id: string) => ({ ok: true, registry: { ...registryConnections, lastUsed: id } }))
|
||||
let bindingAtDial: null | string = null
|
||||
let dials = 0
|
||||
|
||||
desktop.api = vi.fn(async ({ path }: { path: string }) =>
|
||||
path === '/api/profiles/active' ? { active: 'default', current: 'default' } : { profiles: [] }
|
||||
@@ -1050,8 +1049,7 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
|
||||
registryScoped: true
|
||||
}))
|
||||
desktop.getGatewayWsUrlFor = vi.fn(async () => {
|
||||
// Phase 1 (the dial) runs with the previous source still fully bound.
|
||||
bindingAtDial = $activeSessionId.get()
|
||||
dials += 1
|
||||
|
||||
return coderConn.wsUrl
|
||||
})
|
||||
@@ -1078,6 +1076,8 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
|
||||
}
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(dials).toBeGreaterThan(0)) // connected before any pick
|
||||
const dialsBeforePick = dials
|
||||
const switching = selectConnection('coder-remote')
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
@@ -1085,13 +1085,13 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
|
||||
await switching
|
||||
off()
|
||||
|
||||
// The previous backend's runtime id was already gone — and the barrier up —
|
||||
// at every publication of the new source. (Pre-fix: the first publication
|
||||
// carried activeSessionId 'a93bb39d' with the barrier down.)
|
||||
// The previous backend's runtime id was already gone at every publication
|
||||
// of the new source (pre-fix the first one carried 'a93bb39d'). A gateway
|
||||
// pick is a live activation: no switch barrier, no machine-context reset.
|
||||
expect(published.length).toBeGreaterThan(0)
|
||||
expect(published).toEqual(published.map(() => ({ activeSessionId: null, switching: true })))
|
||||
expect(bindingAtDial).toBe('a93bb39d')
|
||||
expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1)
|
||||
expect(published).toEqual(published.map(() => ({ activeSessionId: null, switching: false })))
|
||||
expect(dials).toBe(dialsBeforePick) // the pick dials nothing
|
||||
expect(beforeConnectionSwitch).not.toHaveBeenCalled()
|
||||
expect($connection.get()?.connectionId).toBe('coder-remote')
|
||||
expect(isActivePrimary()).toBe(false)
|
||||
expect($activeSessionId.get()).toBeNull()
|
||||
|
||||
@@ -337,10 +337,8 @@ export function useGatewayBoot({
|
||||
return () => void (cancelled = true)
|
||||
}
|
||||
|
||||
// Store-driven switches (Sessions switcher → selectConnection) commit
|
||||
// through beginGatewaySwitch(), which runs this window's machine-context
|
||||
// reset — the same one a Settings apply (softSwitch below) runs. One owner,
|
||||
// one reset, so the two doors can't drift apart again (#93937).
|
||||
// A Settings apply (softSwitch below) commits through beginGatewaySwitch(),
|
||||
// which runs this window's machine-context reset (#93937).
|
||||
const offSwitchLifecycle = registerGatewaySwitchLifecycle({
|
||||
beforeConnectionSwitch: () => callbacksRef.current.beforeConnectionSwitch(),
|
||||
refreshSessions: shouldPublish => callbacksRef.current.refreshSessions(shouldPublish)
|
||||
|
||||
@@ -408,10 +408,7 @@ export const arCommandCenter = {
|
||||
manageProfiles: 'إدارة الملفات الشخصية',
|
||||
fleet: {
|
||||
localDevice: 'هذا الجهاز (خلفية محلية — تثبّت Hermes إن كان مفقودًا، وإلا تفتح جلسة جديدة)',
|
||||
switchDeviceTitle: 'التبديل إلى هذا الجهاز؟',
|
||||
switchDeviceDesc: 'يفتح هذا جلسة جديدة على هذا الحاسوب. تبقى المحادثة الحالية على البوابة الأخرى.',
|
||||
switchDeviceConfirm: 'تبديل',
|
||||
installDeviceTitle: 'التبديل إلى هذا الجهاز؟',
|
||||
installDeviceTitle: 'تثبيت Hermes على هذا الجهاز؟',
|
||||
installDeviceDesc: 'سيُثبَّت Hermes محليًا ثم تُفتح جلسة جديدة على هذا الحاسوب. لا يبدأ التثبيت قبل التأكيد.',
|
||||
installDeviceConfirm: 'تثبيت محليًا',
|
||||
connectExistingInstead: 'الاتصال بموجود بدلًا من ذلك'
|
||||
|
||||
@@ -1618,7 +1618,7 @@ export const deOverrides = {
|
||||
intro:
|
||||
'Verwalten Sie dieses Gerät und jedes Hermes Gateway, das es über Remote-, SSH- oder Cloud-Verbindungen erreichen kann.',
|
||||
stagedNote:
|
||||
'Wechseln Sie Gateways über Sessions. Profile, Chats, Nachrichten und Cron-Jobs bleiben bei ihrem Gateway; Arbeit auf anderen Gateways läuft weiter.',
|
||||
'Jedes Gateway bleibt verbunden. Profile, Chats, Nachrichten und Cron-Jobs bleiben bei ihrem Gateway, und Sessions kann alle gleichzeitig anzeigen.',
|
||||
launchModeTitle: 'Beim Start zu Sessions auf dem zuletzt verwendeten Gateway zurückkehren',
|
||||
launchModeDesc: 'Wenn deaktiviert, öffnet Sessions auf dem primären Gateway.',
|
||||
searchPlaceholder: 'Gateways durchsuchen…',
|
||||
@@ -3232,15 +3232,11 @@ export const deOverrides = {
|
||||
gateway: gateway => `Profile auf ${gateway}`,
|
||||
gatewayUnreachable: gateway => `${gateway} · nicht erreichbar`,
|
||||
onGateway: (name, gateway) => `${name} · ${gateway}`,
|
||||
switchTo: (name, gateway) => `Zu ${name} auf ${gateway} wechseln`,
|
||||
switchTo: (name, gateway) => `${name} auf ${gateway} öffnen`,
|
||||
deleteOn: gateway => ` auf ${gateway}`,
|
||||
localDevice:
|
||||
'Dieses Gerät (lokales Backend — installiert Hermes, falls es fehlt, sonst öffnet es eine neue Sitzung)',
|
||||
switchDeviceTitle: 'Zu diesem Gerät wechseln?',
|
||||
switchDeviceDesc:
|
||||
'Das öffnet eine neue Sitzung auf diesem Computer. Das aktuelle Gespräch bleibt auf dem anderen Gateway.',
|
||||
switchDeviceConfirm: 'Wechseln',
|
||||
installDeviceTitle: 'Zu diesem Gerät wechseln?',
|
||||
installDeviceTitle: 'Hermes auf diesem Gerät installieren?',
|
||||
installDeviceDesc:
|
||||
'Hermes wird lokal installiert und danach eine neue Sitzung auf diesem Computer geöffnet. Ohne Bestätigung startet keine Installation.',
|
||||
installDeviceConfirm: 'Lokal installieren',
|
||||
|
||||
@@ -1280,7 +1280,7 @@ export const en: Translations = {
|
||||
title: 'Registered gateways',
|
||||
intro: 'Manage this device and every Hermes gateway it can reach through remote, SSH, or Cloud connections.',
|
||||
stagedNote:
|
||||
'Switch gateways from Sessions. Profiles, chats, messaging, and cron jobs stay with their gateway; work on other gateways keeps running.',
|
||||
'Every gateway stays connected. Profiles, chats, messaging, and cron jobs stay with their gateway, and Sessions can show all of them at once.',
|
||||
launchModeTitle: 'At startup, return to Sessions on the last-used gateway',
|
||||
launchModeDesc: 'When off, Sessions opens on the Primary gateway.',
|
||||
searchPlaceholder: 'Search gateways…',
|
||||
@@ -2797,14 +2797,10 @@ export const en: Translations = {
|
||||
gateway: gateway => `Profiles on ${gateway}`,
|
||||
gatewayUnreachable: gateway => `${gateway} · unreachable`,
|
||||
onGateway: (name, gateway) => `${name} · ${gateway}`,
|
||||
switchTo: (name, gateway) => `Switch to ${name} on ${gateway}`,
|
||||
switchTo: (name, gateway) => `Open ${name} on ${gateway}`,
|
||||
deleteOn: gateway => ` on ${gateway}`,
|
||||
localDevice: 'This device (local backend — installs Hermes if missing, otherwise opens a fresh session)',
|
||||
switchDeviceTitle: 'Switch to This device?',
|
||||
switchDeviceDesc:
|
||||
'This opens a fresh session on this computer. The conversation you are in stays on the other gateway.',
|
||||
switchDeviceConfirm: 'Switch',
|
||||
installDeviceTitle: 'Switch to This device?',
|
||||
installDeviceTitle: 'Install Hermes on This device?',
|
||||
installDeviceDesc:
|
||||
'This will install Hermes locally, then open a fresh session on this computer. Nothing is installed until you confirm.',
|
||||
installDeviceConfirm: 'Install locally',
|
||||
|
||||
@@ -1617,7 +1617,7 @@ export const esOverrides = {
|
||||
intro:
|
||||
'Gestiona este dispositivo y cada gateway de Hermes al que puede llegar mediante conexiones remotas, SSH o Cloud.',
|
||||
stagedNote:
|
||||
'Cambia de gateway desde Sesiones. Los perfiles, chats, mensajería y tareas cron se quedan con su gateway; el trabajo en otros gateways sigue ejecutándose.',
|
||||
'Todos los gateways permanecen conectados. Los perfiles, chats, mensajería y tareas cron se quedan con su gateway, y Sesiones puede mostrarlos todos a la vez.',
|
||||
launchModeTitle: 'Al iniciar, volver a Sesiones en el último gateway usado',
|
||||
launchModeDesc: 'Si está desactivado, Sesiones se abre en el gateway principal.',
|
||||
searchPlaceholder: 'Buscar gateways…',
|
||||
@@ -3221,14 +3221,10 @@ export const esOverrides = {
|
||||
gateway: (gateway: string) => `Perfiles en ${gateway}`,
|
||||
gatewayUnreachable: (gateway: string) => `${gateway} · inaccesible`,
|
||||
onGateway: (name: string, gateway: string) => `${name} · ${gateway}`,
|
||||
switchTo: (name: string, gateway: string) => `Cambiar a ${name} en ${gateway}`,
|
||||
switchTo: (name: string, gateway: string) => `Abrir ${name} en ${gateway}`,
|
||||
deleteOn: (gateway: string) => ` en ${gateway}`,
|
||||
localDevice: 'Este dispositivo (backend local: instala Hermes si falta; si no, abre una sesión nueva)',
|
||||
switchDeviceTitle: '¿Cambiar a este dispositivo?',
|
||||
switchDeviceDesc:
|
||||
'Esto abre una sesión nueva en este equipo. La conversación actual permanece en el otro gateway.',
|
||||
switchDeviceConfirm: 'Cambiar',
|
||||
installDeviceTitle: '¿Cambiar a este dispositivo?',
|
||||
installDeviceTitle: '¿Instalar Hermes en este dispositivo?',
|
||||
installDeviceDesc:
|
||||
'Esto instalará Hermes localmente y luego abrirá una sesión nueva en este equipo. No se instala nada hasta que confirmes.',
|
||||
installDeviceConfirm: 'Instalar localmente',
|
||||
|
||||
@@ -1622,7 +1622,7 @@ export const frOverrides = {
|
||||
intro:
|
||||
'Enregistrez tous les emplacements où vivent vos agents : cet appareil, les gateways distantes de votre réseau et les instances Hermes Cloud. Ils sont tous conservés ici.',
|
||||
stagedNote:
|
||||
"Les conversations et la liste des agents suivent la source choisie ; le backend de fenêtre géré par l'application reste sélectionné dans Paramètres → Gateway.",
|
||||
'Chaque gateway reste connectée. Les profils, conversations, messageries et tâches cron restent sur leur gateway, et Sessions peut toutes les afficher à la fois.',
|
||||
launchModeTitle: 'Au démarrage, revenir aux sessions de la dernière gateway utilisée',
|
||||
launchModeDesc: "Si cette option est désactivée, les sessions s'ouvrent sur la gateway principale.",
|
||||
searchPlaceholder: 'Rechercher des gateways…',
|
||||
@@ -3237,14 +3237,10 @@ export const frOverrides = {
|
||||
gateway: gateway => `Profils sur ${gateway}`,
|
||||
gatewayUnreachable: gateway => `${gateway} · inaccessible`,
|
||||
onGateway: (name, gateway) => `${name} · ${gateway}`,
|
||||
switchTo: (name, gateway) => `Basculer vers ${name} sur ${gateway}`,
|
||||
switchTo: (name, gateway) => `Ouvrir ${name} sur ${gateway}`,
|
||||
deleteOn: gateway => ` sur ${gateway}`,
|
||||
localDevice: 'Cet appareil (backend local — installe Hermes s’il manque, sinon ouvre une nouvelle session)',
|
||||
switchDeviceTitle: 'Basculer vers cet appareil ?',
|
||||
switchDeviceDesc:
|
||||
'Cela ouvre une nouvelle session sur cet ordinateur. La conversation en cours reste sur l’autre gateway.',
|
||||
switchDeviceConfirm: 'Basculer',
|
||||
installDeviceTitle: 'Basculer vers cet appareil ?',
|
||||
installDeviceTitle: 'Installer Hermes sur cet appareil ?',
|
||||
installDeviceDesc:
|
||||
'Hermes sera installé localement, puis une nouvelle session s’ouvrira sur cet ordinateur. Rien n’est installé tant que vous ne confirmez pas.',
|
||||
installDeviceConfirm: 'Installer localement',
|
||||
|
||||
@@ -1795,10 +1795,7 @@ export const jaOverrides = {
|
||||
fleet: {
|
||||
localDevice:
|
||||
'このデバイス(ローカルバックエンド — Hermes が無ければインストールし、あれば新しいセッションを開きます)',
|
||||
switchDeviceTitle: 'このデバイスに切り替えますか?',
|
||||
switchDeviceDesc: 'このコンピュータで新しいセッションを開きます。今の会話は別のゲートウェイに残ります。',
|
||||
switchDeviceConfirm: '切り替える',
|
||||
installDeviceTitle: 'このデバイスに切り替えますか?',
|
||||
installDeviceTitle: 'このデバイスに Hermes をインストールしますか?',
|
||||
installDeviceDesc:
|
||||
'Hermes をローカルにインストールしてから、このコンピュータで新しいセッションを開きます。確認するまでインストールは始まりません。',
|
||||
installDeviceConfirm: 'ローカルにインストール',
|
||||
|
||||
@@ -1005,7 +1005,7 @@ export const ruOverrides = {
|
||||
intro:
|
||||
'Управляйте этим устройством и всеми шлюзами Hermes, до которых можно дотянуться через удалённые, SSH или Cloud-соединения.',
|
||||
stagedNote:
|
||||
'Переключайтесь между шлюзами из раздела «Сеансы». Профили, чаты, мессенджеры и cron-задачи остаются за своим шлюзом; работа на других шлюзах продолжается.',
|
||||
'Все шлюзы остаются подключёнными. Профили, чаты, мессенджеры и cron-задачи остаются за своим шлюзом, а «Сеансы» могут показывать их все сразу.',
|
||||
launchModeTitle: 'При запуске возвращаться к «Сеансам» на последнем шлюзе',
|
||||
launchModeDesc: 'Если выключено, «Сеансы» открываются на основном шлюзе.',
|
||||
searchPlaceholder: 'Поиск шлюзов…',
|
||||
@@ -2084,10 +2084,7 @@ export const ruOverrides = {
|
||||
connectGateway: 'Управлять шлюзами…',
|
||||
fleet: {
|
||||
localDevice: 'Это устройство (локальный бэкенд — установит Hermes, если его нет, иначе откроет новую сессию)',
|
||||
switchDeviceTitle: 'Переключиться на это устройство?',
|
||||
switchDeviceDesc: 'Откроется новая сессия на этом компьютере. Текущий разговор останется на другом шлюзе.',
|
||||
switchDeviceConfirm: 'Переключить',
|
||||
installDeviceTitle: 'Переключиться на это устройство?',
|
||||
installDeviceTitle: 'Установить Hermes на это устройство?',
|
||||
installDeviceDesc:
|
||||
'Hermes будет установлен локально, затем откроется новая сессия на этом компьютере. Установка не начнётся, пока вы не подтвердите.',
|
||||
installDeviceConfirm: 'Установить локально',
|
||||
|
||||
@@ -2464,9 +2464,6 @@ export interface Translations extends NoticeTranslations {
|
||||
deleteOn: (gateway: string) => string
|
||||
/** At-rest local default pill: device, not Home, and the click's consequence. */
|
||||
localDevice: string
|
||||
switchDeviceTitle: string
|
||||
switchDeviceDesc: string
|
||||
switchDeviceConfirm: string
|
||||
installDeviceTitle: string
|
||||
installDeviceDesc: string
|
||||
installDeviceConfirm: string
|
||||
|
||||
@@ -331,10 +331,7 @@ export const zhHantCommandCenter = {
|
||||
manageProfiles: '管理設定檔…',
|
||||
fleet: {
|
||||
localDevice: '此裝置(本機後端——若未安裝 Hermes 則會安裝,否則開啟新的工作階段)',
|
||||
switchDeviceTitle: '切換到此裝置?',
|
||||
switchDeviceDesc: '這會在這台電腦上開啟新的工作階段。目前的對話仍留在另一個閘道。',
|
||||
switchDeviceConfirm: '切換',
|
||||
installDeviceTitle: '切換到此裝置?',
|
||||
installDeviceTitle: '在此裝置上安裝 Hermes?',
|
||||
installDeviceDesc: '這會在本機安裝 Hermes,然後在這台電腦上開啟新的工作階段。確認之前不會開始安裝。',
|
||||
installDeviceConfirm: '本機安裝',
|
||||
connectExistingInstead: '改為連線現有環境'
|
||||
|
||||
@@ -1322,8 +1322,7 @@ export const zhOverrides = {
|
||||
connections: {
|
||||
title: '已注册网关',
|
||||
intro: '管理本机以及通过远程、SSH 或 Hermes Cloud 连接可访问的每个 Hermes 网关。',
|
||||
stagedNote:
|
||||
'可在“会话”侧边栏切换网关。配置档案、聊天、消息和定时任务归属于各自网关;其他网关上的工作会继续运行。',
|
||||
stagedNote: '所有网关都保持连接。配置档案、聊天、消息和定时任务归属于各自网关,“会话”可以同时显示全部网关。',
|
||||
launchModeTitle: '启动时返回上次使用网关的会话视图',
|
||||
launchModeDesc: '关闭时,会话将在主网关上打开。',
|
||||
searchPlaceholder: '搜索网关…',
|
||||
@@ -2712,13 +2711,10 @@ export const zhOverrides = {
|
||||
gateway: gateway => `${gateway} 上的配置档案`,
|
||||
gatewayUnreachable: gateway => `${gateway} · 无法连接`,
|
||||
onGateway: (name, gateway) => `${name} · ${gateway}`,
|
||||
switchTo: (name, gateway) => `切换到 ${gateway} 上的 ${name}`,
|
||||
switchTo: (name, gateway) => `打开 ${gateway} 上的 ${name}`,
|
||||
deleteOn: gateway => `(位于 ${gateway})`,
|
||||
localDevice: '此设备(本地后端——若未安装 Hermes 则会安装,否则打开一个新会话)',
|
||||
switchDeviceTitle: '切换到此设备?',
|
||||
switchDeviceDesc: '这会在这台电脑上打开一个新会话。当前对话仍留在另一个网关上。',
|
||||
switchDeviceConfirm: '切换',
|
||||
installDeviceTitle: '切换到此设备?',
|
||||
installDeviceTitle: '在此设备上安装 Hermes?',
|
||||
installDeviceDesc: '这将在本地安装 Hermes,然后在这台电脑上打开一个新会话。确认之前不会开始安装。',
|
||||
installDeviceConfirm: '本地安装',
|
||||
connectExistingInstead: '改为连接现有环境'
|
||||
|
||||
@@ -19,10 +19,9 @@ const $connection = atom<null | {
|
||||
registryScoped?: boolean
|
||||
}>(null)
|
||||
|
||||
// The runtime session id minted by the CURRENT backend — the binding a switch
|
||||
// must sever before the next source is published (#93937).
|
||||
// The runtime session id minted by the CURRENT backend — the binding a pick
|
||||
// must drop before the next gateway is published (#93937).
|
||||
const $activeSessionId = atom<null | string>(null)
|
||||
const $gatewaySwitching = atom(false)
|
||||
|
||||
interface ActivationOptions {
|
||||
beforeActivate?: () => boolean
|
||||
@@ -36,39 +35,17 @@ const ensureGatewayAgent = vi.fn(
|
||||
const openGatewayAgent = vi.fn(async (_connectionId: string, _profile: string): Promise<void> => undefined)
|
||||
const refreshActiveProfile = vi.fn(async () => undefined)
|
||||
const requestFreshSession = vi.fn()
|
||||
const beforeConnectionSwitch = vi.fn()
|
||||
const wipeSessionListsForGatewaySwitch = vi.fn(() => $activeSessionId.set(null))
|
||||
const exitProjectScope = vi.fn()
|
||||
|
||||
// Test double for the store's commit point with the real one's contract
|
||||
// (barrier → machine-context reset → wipe, synchronously; the barrier is
|
||||
// owned by the latest token); the real implementation is covered by
|
||||
// gateway-switch.test.ts.
|
||||
let latestSwitchToken = 0
|
||||
|
||||
const beginGatewaySwitch = vi.fn(() => {
|
||||
$gatewaySwitching.set(true)
|
||||
beforeConnectionSwitch()
|
||||
wipeSessionListsForGatewaySwitch()
|
||||
|
||||
return ++latestSwitchToken
|
||||
})
|
||||
|
||||
const endGatewaySwitch = vi.fn((token?: number) => {
|
||||
if (token === undefined || token === latestSwitchToken) {
|
||||
$gatewaySwitching.set(false)
|
||||
}
|
||||
})
|
||||
|
||||
const recoverActiveSourceAfterFailedGatewaySwitch = vi.fn()
|
||||
|
||||
vi.mock('@/store/session', () => ({ $connection, $activeSessionId, $selectedStoredSessionId: atom(null) }))
|
||||
vi.mock('@/store/gateway-switch', () => ({
|
||||
$gatewaySwitching,
|
||||
beginGatewaySwitch,
|
||||
endGatewaySwitch,
|
||||
recoverActiveSourceAfterFailedGatewaySwitch,
|
||||
wipeSessionListsForGatewaySwitch
|
||||
vi.mock('@/store/session', () => ({
|
||||
$connection,
|
||||
$activeSessionId,
|
||||
$selectedStoredSessionId: atom(null),
|
||||
setActiveSessionId: (id: null | string) => $activeSessionId.set(id),
|
||||
setSelectedStoredSessionId: vi.fn()
|
||||
}))
|
||||
vi.mock('@/store/project-scope', () => ({ exitProjectScope }))
|
||||
vi.mock('@/store/gateway-switch', () => ({ supersedeGatewaySwitch: vi.fn() }))
|
||||
vi.mock('@/store/profile', () => ({
|
||||
$activeGatewayProfile,
|
||||
$freshSessionRequest,
|
||||
@@ -119,7 +96,7 @@ beforeEach(() => {
|
||||
$newChatProfile.set(null)
|
||||
$showAllProfiles.set(false)
|
||||
ensureGatewayAgent.mockReset()
|
||||
// Mirrors the real door: the commit hook runs right before the activation
|
||||
// Mirrors the real door: beforeActivate runs right before the activation
|
||||
// publishes, and a declined hook publishes nothing.
|
||||
ensureGatewayAgent.mockImplementation(async (connectionId, profile, options) => {
|
||||
if (options?.beforeActivate && !options.beforeActivate()) {
|
||||
@@ -140,13 +117,8 @@ beforeEach(() => {
|
||||
openGatewayAgent.mockResolvedValue(undefined)
|
||||
refreshActiveProfile.mockClear()
|
||||
requestFreshSession.mockClear()
|
||||
beforeConnectionSwitch.mockClear()
|
||||
beginGatewaySwitch.mockClear()
|
||||
endGatewaySwitch.mockClear()
|
||||
recoverActiveSourceAfterFailedGatewaySwitch.mockClear()
|
||||
wipeSessionListsForGatewaySwitch.mockClear()
|
||||
exitProjectScope.mockClear()
|
||||
$activeSessionId.set(null)
|
||||
$gatewaySwitching.set(false)
|
||||
list.mockClear()
|
||||
setLastUsed.mockClear()
|
||||
api.mockReset()
|
||||
@@ -271,18 +243,17 @@ describe('connection registry cache', () => {
|
||||
})
|
||||
|
||||
describe('selectConnection', () => {
|
||||
it('dials a secondary source and starts a fresh source-scoped draft', async () => {
|
||||
it('activates the picked gateway in place and starts a fresh draft there, with no wipe', async () => {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
|
||||
await selectConnection('homelab')
|
||||
|
||||
expect(openGatewayAgent).toHaveBeenCalledWith('homelab', 'default')
|
||||
expect(ensureGatewayAgent).toHaveBeenCalledTimes(1)
|
||||
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
|
||||
expect(api).not.toHaveBeenCalled()
|
||||
expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1)
|
||||
expect(requestFreshSession).toHaveBeenCalledTimes(1)
|
||||
expect(wipeSessionListsForGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect(exitProjectScope).toHaveBeenCalledTimes(1)
|
||||
expect($newChatProfile.get()).toBe('default')
|
||||
expect(refreshActiveProfile).toHaveBeenCalledTimes(1)
|
||||
expect(setLastUsed).toHaveBeenCalledWith('homelab')
|
||||
@@ -308,41 +279,6 @@ describe('selectConnection', () => {
|
||||
expect(api).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a later source choice win while an earlier dial is still pending', async () => {
|
||||
let releaseDials!: () => void
|
||||
|
||||
const dialGate = new Promise<void>(resolve => {
|
||||
releaseDials = resolve
|
||||
})
|
||||
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
openGatewayAgent.mockImplementation(async () => {
|
||||
await dialGate
|
||||
})
|
||||
|
||||
const openHomelab = selectConnection('homelab')
|
||||
await Promise.resolve()
|
||||
const stayLocal = selectConnection('local')
|
||||
|
||||
releaseDials()
|
||||
await Promise.all([openHomelab, stayLocal])
|
||||
|
||||
expect(openGatewayAgent.mock.calls).toEqual([
|
||||
['homelab', 'default'],
|
||||
['local', 'default']
|
||||
])
|
||||
// The superseded dial never activates: the user doesn't flip through
|
||||
// homelab on the way back to local, and only the winner commits.
|
||||
expect(ensureGatewayAgent.mock.calls.map(call => [call[0], call[1]])).toEqual([['local', 'default']])
|
||||
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect(wipeSessionListsForGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
// Only the latest intent repaints the profile list.
|
||||
expect(refreshActiveProfile).toHaveBeenCalledTimes(1)
|
||||
expect($connection.get()?.connectionId).toBe('local')
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('lands on an explicit profile instead of the one last used on that source', async () => {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
@@ -394,7 +330,7 @@ describe('selectConnection', () => {
|
||||
expect(ensureGatewayAgent).toHaveBeenCalledWith('local', 'default', expect.anything())
|
||||
})
|
||||
|
||||
it('keeps the current source usable when a dial fails: nothing is severed before the target is reachable', async () => {
|
||||
it('a gateway that is down fails at the open and touches nothing in front', async () => {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
$activeSessionId.set('a93bb39d')
|
||||
@@ -402,14 +338,8 @@ describe('selectConnection', () => {
|
||||
|
||||
await expect(selectConnection('homelab')).rejects.toThrow('offline')
|
||||
|
||||
// The dial failed in phase 1 — the switch never committed, so the open
|
||||
// transcript, its runtime binding and the session lists are all intact.
|
||||
expect(ensureGatewayAgent).not.toHaveBeenCalled()
|
||||
expect(beginGatewaySwitch).not.toHaveBeenCalled()
|
||||
expect(beforeConnectionSwitch).not.toHaveBeenCalled()
|
||||
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
|
||||
expect($activeSessionId.get()).toBe('a93bb39d')
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
expect(requestFreshSession).not.toHaveBeenCalled()
|
||||
expect($newChatProfile.get()).toBeNull()
|
||||
expect($pendingConnectionId.get()).toBeNull()
|
||||
@@ -417,142 +347,50 @@ describe('selectConnection', () => {
|
||||
expect($connection.get()?.connectionId).toBe('local')
|
||||
})
|
||||
|
||||
it.each(['remote', 'cloud'] as const)(
|
||||
'requires protected REST auth on a warm %s socket before discarding the current workspace',
|
||||
async kind => {
|
||||
const oauthRegistry: DesktopConnectionsRegistry = {
|
||||
...registry,
|
||||
connections: registry.connections.map(connection =>
|
||||
connection.id === 'homelab' ? { ...connection, kind, authMode: 'oauth' } : connection
|
||||
)
|
||||
}
|
||||
|
||||
setConnectionsRegistry(oauthRegistry)
|
||||
const source = { connectionId: 'work-vps', mode: 'remote' as const, profile: 'research', registryScoped: true }
|
||||
$connection.set(source)
|
||||
$activeGatewayProfile.set('research')
|
||||
$activeSessionId.set('source-runtime')
|
||||
$newChatProfile.set('research')
|
||||
$showAllProfiles.set(true)
|
||||
setApiRequestConnection('work-vps')
|
||||
setApiRequestProfile('research')
|
||||
setLastUsed.mockImplementationOnce(async id => ({ ok: true, registry: { ...oauthRegistry, lastUsed: id } }))
|
||||
|
||||
// A retained socket already passed its handshake; opening it succeeds
|
||||
// even though fresh REST requests no longer authenticate. Connectivity
|
||||
// errors must also preserve the workspace, without being called sign-in.
|
||||
const expired = new Error(kind === 'remote' ? '401 Unauthorized: no_cookie' : '403 Forbidden: session expired')
|
||||
const offline = new Error('net::ERR_CONNECTION_RESET')
|
||||
api.mockRejectedValueOnce(expired).mockRejectedValueOnce(offline)
|
||||
|
||||
for (const error of [expired, offline]) {
|
||||
await expect(selectConnection('homelab', { profile: 'scout' })).rejects.toBe(error)
|
||||
expect($connection.get()).toBe(source)
|
||||
expect($activeConnectionId.get()).toBe('work-vps')
|
||||
expect($activeGatewayProfile.get()).toBe('research')
|
||||
expect($activeSessionId.get()).toBe('source-runtime')
|
||||
expect($newChatProfile.get()).toBe('research')
|
||||
expect($showAllProfiles.get()).toBe(true)
|
||||
expect($pendingConnectionId.get()).toBeNull()
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
expect(ensureGatewayAgent).not.toHaveBeenCalled()
|
||||
expect(beginGatewaySwitch).not.toHaveBeenCalled()
|
||||
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
|
||||
expect(requestFreshSession).not.toHaveBeenCalled()
|
||||
expect(refreshActiveProfile).not.toHaveBeenCalled()
|
||||
expect(setLastUsed).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
// Auth is refreshed externally; the very next click must work with the
|
||||
// same module and warm socket, without a cached failed readiness result.
|
||||
await selectConnection('homelab', { profile: 'scout' })
|
||||
expect(api.mock.calls.map(call => (call as unknown[])[0])).toEqual(
|
||||
Array.from({ length: 3 }, () =>
|
||||
expect.objectContaining({ connectionId: 'homelab', profile: 'scout', path: '/api/profiles' })
|
||||
)
|
||||
)
|
||||
expect(openGatewayAgent.mock.calls).toEqual(Array.from({ length: 3 }, () => ['homelab', 'scout']))
|
||||
expect(ensureGatewayAgent).toHaveBeenCalledTimes(1)
|
||||
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect(api.mock.invocationCallOrder[2]).toBeLessThan(beginGatewaySwitch.mock.invocationCallOrder[0])
|
||||
expect($activeConnectionId.get()).toBe('homelab')
|
||||
expect($newChatProfile.get()).toBe('scout')
|
||||
expect($showAllProfiles.get()).toBe(false)
|
||||
expect($activeSessionId.get()).toBeNull()
|
||||
expect(setLastUsed).toHaveBeenCalledWith('homelab')
|
||||
}
|
||||
)
|
||||
|
||||
it('an activation that does not land after the wipe lowers the barrier and repaints the still-active source', async () => {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
// The dial opened the socket but the activation was declined (source
|
||||
// edited/removed mid-switch): the commit hook ran (wipe done), yet
|
||||
// $connection never moves to homelab.
|
||||
ensureGatewayAgent.mockImplementationOnce(async (_connectionId, _profile, options) => {
|
||||
options?.beforeActivate?.()
|
||||
})
|
||||
|
||||
await expect(selectConnection('homelab')).rejects.toThrow('did not become active')
|
||||
|
||||
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect(endGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
// The lists were wiped for a commit that never happened; the source that
|
||||
// is still active gets repainted and the user lands on a fresh draft there.
|
||||
expect(recoverActiveSourceAfterFailedGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect(requestFreshSession).toHaveBeenCalledTimes(1)
|
||||
expect(setLastUsed).not.toHaveBeenCalled()
|
||||
expect($newChatProfile.get()).toBeNull()
|
||||
expect($pendingConnectionId.get()).toBeNull()
|
||||
expect($connection.get()?.connectionId).toBe('local')
|
||||
})
|
||||
|
||||
it("#93937: severs the previous source's runtime session binding BEFORE the new source is published, behind the barrier", async () => {
|
||||
it("#93937: the previous gateway's runtime is unbound BEFORE the picked gateway is published", async () => {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
// Runtime id minted by the local backend; only local has ever heard of it.
|
||||
$activeSessionId.set('a93bb39d')
|
||||
|
||||
// Phase 1 (the dial) must not touch the current workspace at all.
|
||||
openGatewayAgent.mockImplementationOnce(async () => {
|
||||
expect($activeSessionId.get()).toBe('a93bb39d')
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Every publication of the new source, with what a session-scoped effect
|
||||
// would read at that instant.
|
||||
const published: Array<{ activeSessionId: null | string; connectionId?: string; switching: boolean }> = []
|
||||
const published: Array<{ activeSessionId: null | string; connectionId?: string; drafted: number }> = []
|
||||
|
||||
const off = $connection.listen(next => {
|
||||
published.push({
|
||||
activeSessionId: $activeSessionId.get(),
|
||||
connectionId: next?.connectionId,
|
||||
switching: $gatewaySwitching.get()
|
||||
drafted: requestFreshSession.mock.calls.length
|
||||
})
|
||||
})
|
||||
|
||||
await selectConnection('homelab')
|
||||
off()
|
||||
|
||||
// The old runtime id was already gone when homelab became visible, and
|
||||
// the barrier was up — nothing could pair 'a93bb39d' with the new backend.
|
||||
expect(published).toEqual([{ activeSessionId: null, connectionId: 'homelab', switching: true }])
|
||||
// dial → commit (barrier + reset + wipe, inside the activation's commit
|
||||
// hook) → publish, in that order.
|
||||
expect(openGatewayAgent).toHaveBeenCalledWith('homelab', 'default')
|
||||
expect(openGatewayAgent.mock.invocationCallOrder[0]).toBeLessThan(ensureGatewayAgent.mock.invocationCallOrder[0])
|
||||
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1)
|
||||
expect(endGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
expect($activeSessionId.get()).toBeNull()
|
||||
expect(published).toEqual([{ activeSessionId: null, connectionId: 'homelab', drafted: 1 }])
|
||||
})
|
||||
|
||||
it('a click that supersedes a QUEUED commit wins: the superseded switch neither wipes nor activates', async () => {
|
||||
// Both activations sit behind an in-flight profile/agent switch (the
|
||||
// profile store's mutex); their commit hooks only run once it settles.
|
||||
it('a failed activation leaves the gateway in front usable and points the draft back at it', async () => {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
// The pick reached its turn (draft rehomed), then the dial failed.
|
||||
ensureGatewayAgent.mockImplementationOnce(async (_connectionId, _profile, options) => {
|
||||
options?.beforeActivate?.()
|
||||
|
||||
throw new Error('offline')
|
||||
})
|
||||
|
||||
await expect(selectConnection('homelab')).rejects.toThrow('offline')
|
||||
|
||||
expect($connection.get()?.connectionId).toBe('local')
|
||||
expect($newChatProfile.get()).toBe('default')
|
||||
expect(requestFreshSession).toHaveBeenCalledTimes(2)
|
||||
expect(setLastUsed).not.toHaveBeenCalled()
|
||||
expect($pendingConnectionId.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('a pick superseded while queued neither drafts nor activates', async () => {
|
||||
// Both activations sit behind an in-flight profile/agent activation (the
|
||||
// profile store's mutex); their beforeActivate hooks only run once it settles.
|
||||
const mutex = deferred()
|
||||
|
||||
setConnectionsRegistry(registry)
|
||||
@@ -581,71 +419,23 @@ describe('selectConnection', () => {
|
||||
const second = selectConnection('work-vps')
|
||||
await vi.waitFor(() => expect(ensureGatewayAgent).toHaveBeenCalledTimes(2))
|
||||
|
||||
// Nothing is severed while both are still queued.
|
||||
expect(beginGatewaySwitch).not.toHaveBeenCalled()
|
||||
// Nothing is touched while both are still queued.
|
||||
expect($activeSessionId.get()).toBe('a93bb39d')
|
||||
expect(requestFreshSession).not.toHaveBeenCalled()
|
||||
|
||||
mutex.resolve()
|
||||
await Promise.all([first, second])
|
||||
off()
|
||||
|
||||
// homelab stepped aside at its turn: no wipe, no publication, no error
|
||||
// UI; work-vps wiped once and is the only source ever published.
|
||||
expect(ensureGatewayAgent.mock.calls.map(call => call[0])).toEqual(['homelab', 'work-vps'])
|
||||
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
// homelab stepped aside at its turn: no draft, no publication, no error.
|
||||
expect(published).toEqual(['work-vps:active=null'])
|
||||
expect($connection.get()?.connectionId).toBe('work-vps')
|
||||
expect(recoverActiveSourceAfterFailedGatewaySwitch).not.toHaveBeenCalled()
|
||||
expect(requestFreshSession).toHaveBeenCalledTimes(1)
|
||||
expect(setLastUsed).toHaveBeenCalledTimes(1)
|
||||
expect(setLastUsed).toHaveBeenCalledWith('work-vps')
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
expect($pendingConnectionId.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not spend the activation timeout while waiting for the serialized commit turn', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const mutex = deferred()
|
||||
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
ensureGatewayAgent.mockImplementationOnce(async (connectionId, _profile, options) => {
|
||||
await mutex.promise
|
||||
|
||||
if (options?.beforeActivate && !options.beforeActivate()) {
|
||||
return
|
||||
}
|
||||
|
||||
$connection.set({
|
||||
connectionId: connectionId ?? undefined,
|
||||
mode: 'remote',
|
||||
profile: 'default',
|
||||
registryScoped: true
|
||||
})
|
||||
})
|
||||
|
||||
const attempt = selectConnection('homelab')
|
||||
await vi.waitFor(() => expect(ensureGatewayAgent).toHaveBeenCalledTimes(1))
|
||||
|
||||
// Queue ownership belongs to the shared mutex, not the actual activation
|
||||
// attempt, so waiting here must not consume its 20-second commit budget.
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(beginGatewaySwitch).not.toHaveBeenCalled()
|
||||
|
||||
mutex.resolve()
|
||||
await attempt
|
||||
|
||||
expect($connection.get()?.connectionId).toBe('homelab')
|
||||
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a slow-but-healthy dial is not a failed switch: the source caps only past the ssh layer budget, and the click lands', async () => {
|
||||
it('a slow-but-healthy cold dial is not a failed pick: it caps only past the ssh layer budget', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
@@ -653,7 +443,10 @@ describe('selectConnection', () => {
|
||||
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
openGatewayAgent.mockImplementationOnce(() => dial.promise)
|
||||
ensureGatewayAgent.mockImplementationOnce(async (connectionId, profile) => {
|
||||
await dial.promise
|
||||
$connection.set({ connectionId: connectionId ?? undefined, mode: 'remote', profile, registryScoped: true })
|
||||
})
|
||||
|
||||
let outcome = 'pending'
|
||||
|
||||
@@ -666,11 +459,8 @@ describe('selectConnection', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// A cold remote dial is a chain of main-process stages (ssh connect, the
|
||||
// platform/locate/version execs, the spawned backend's ready sentinel, the
|
||||
// forward) that routinely outruns the 20 s reconnect-class budget: measured
|
||||
// 37-64 s against a small VPS. The switch must keep waiting instead of
|
||||
// toasting "Could not connect to <source>" for a source that is arriving.
|
||||
// A cold remote dial (ssh connect, locate/version execs, the spawned
|
||||
// backend's ready sentinel, the forward) routinely runs 37-64 s.
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(outcome).toBe('pending')
|
||||
expect($pendingConnectionId.get()).toBe('homelab')
|
||||
@@ -680,197 +470,12 @@ describe('selectConnection', () => {
|
||||
|
||||
expect(outcome).toBe('resolved')
|
||||
expect($connection.get()?.connectionId).toBe('homelab')
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a dial that never answers times out: nothing severed, the click fails visibly, and the source can be retried', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
$activeSessionId.set('a93bb39d')
|
||||
openGatewayAgent.mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
|
||||
const outcome = selectConnection('homelab').then(
|
||||
() => 'resolved',
|
||||
(error: Error) => error.message
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SOURCE_SWITCH_DIAL_TIMEOUT_MS)
|
||||
|
||||
expect(await outcome).toMatch(/Timed out connecting to "Homelab"/)
|
||||
expect(ensureGatewayAgent).not.toHaveBeenCalled()
|
||||
expect(beginGatewaySwitch).not.toHaveBeenCalled()
|
||||
expect($activeSessionId.get()).toBe('a93bb39d')
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
expect($pendingConnectionId.get()).toBeNull()
|
||||
|
||||
// The stalled click does not poison the source: a retry is a real switch,
|
||||
// not a duplicate of the pending one.
|
||||
await selectConnection('homelab')
|
||||
|
||||
expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything())
|
||||
expect($connection.get()?.connectionId).toBe('homelab')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('an activation that published the new source but never settled counts as committed', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
// The socket activates and publishes synchronously; only the trailing
|
||||
// descriptor resync (an IPC) stalls.
|
||||
ensureGatewayAgent.mockImplementationOnce((connectionId, _profile, options) => {
|
||||
options?.beforeActivate?.()
|
||||
$connection.set({
|
||||
connectionId: connectionId ?? undefined,
|
||||
mode: 'remote',
|
||||
profile: 'default',
|
||||
registryScoped: true
|
||||
})
|
||||
|
||||
return new Promise<void>(() => undefined)
|
||||
})
|
||||
|
||||
const attempt = selectConnection('homelab')
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await attempt
|
||||
|
||||
expect($connection.get()?.connectionId).toBe('homelab')
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
expect(setLastUsed).toHaveBeenCalledWith('homelab')
|
||||
expect(requestFreshSession).toHaveBeenCalledTimes(1)
|
||||
expect(recoverActiveSourceAfterFailedGatewaySwitch).not.toHaveBeenCalled()
|
||||
expect($pendingConnectionId.get()).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a timed-out activation that already published cannot republish after a newer source wins', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
let releaseDescriptor: () => void = () => undefined
|
||||
|
||||
setConnectionsRegistry(registry)
|
||||
// Seed A's remembered profile through the real source/profile observer,
|
||||
// then restore the currently active local source.
|
||||
$activeGatewayProfile.set('research')
|
||||
$connection.set({ connectionId: 'homelab', mode: 'remote', profile: 'research', registryScoped: true })
|
||||
$activeGatewayProfile.set('default')
|
||||
$connection.set({ connectionId: 'local', mode: 'local', profile: 'default', registryScoped: true })
|
||||
|
||||
ensureGatewayAgent
|
||||
.mockImplementationOnce((connectionId, profile, options) => {
|
||||
options?.beforeActivate?.()
|
||||
|
||||
// Low-level activation publishes synchronously. The trailing descriptor
|
||||
// promise remains alive beyond selectConnection's commit timeout.
|
||||
$activeGatewayProfile.set(profile)
|
||||
$connection.set({
|
||||
connectionId: connectionId ?? undefined,
|
||||
mode: 'remote',
|
||||
profile,
|
||||
registryScoped: true
|
||||
})
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
releaseDescriptor = () => {
|
||||
// Mirrors ensureGatewayAgent's publication seam: a revoked owner
|
||||
// observes its signal and must not publish its late descriptor.
|
||||
if (!options?.signal?.aborted) {
|
||||
$activeGatewayProfile.set(profile)
|
||||
$connection.set({
|
||||
connectionId: connectionId ?? undefined,
|
||||
mode: 'remote',
|
||||
profile,
|
||||
registryScoped: true
|
||||
})
|
||||
}
|
||||
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
.mockImplementationOnce(async (connectionId, profile, options) => {
|
||||
if (options?.beforeActivate && !options.beforeActivate()) {
|
||||
return
|
||||
}
|
||||
|
||||
$activeGatewayProfile.set(profile)
|
||||
$connection.set({
|
||||
connectionId: connectionId ?? undefined,
|
||||
mode: 'remote',
|
||||
profile,
|
||||
registryScoped: true
|
||||
})
|
||||
})
|
||||
|
||||
const timedOutOwner = selectConnection('homelab')
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await timedOutOwner
|
||||
|
||||
// Fail open: A really did become active before its trailing descriptor
|
||||
// work timed out, so the commit remains successful.
|
||||
expect($activeGatewayProfile.get()).toBe('research')
|
||||
expect($connection.get()?.connectionId).toBe('homelab')
|
||||
|
||||
await selectConnection('work-vps')
|
||||
expect($activeGatewayProfile.get()).toBe('default')
|
||||
expect($connection.get()?.connectionId).toBe('work-vps')
|
||||
|
||||
releaseDescriptor()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect($activeGatewayProfile.get()).toBe('default')
|
||||
expect($connection.get()?.connectionId).toBe('work-vps')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('an activation that stalls AFTER the wipe times out: barrier down, still-active source repainted', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
ensureGatewayAgent.mockImplementationOnce((_connectionId, _profile, options) => {
|
||||
options?.beforeActivate?.()
|
||||
|
||||
return new Promise<void>(() => undefined)
|
||||
})
|
||||
|
||||
const outcome = selectConnection('homelab').then(
|
||||
() => 'resolved',
|
||||
(error: Error) => error.message
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
|
||||
expect(await outcome).toMatch(/Timed out activating "Homelab"/)
|
||||
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
expect(recoverActiveSourceAfterFailedGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect(requestFreshSession).toHaveBeenCalledTimes(1)
|
||||
expect(setLastUsed).not.toHaveBeenCalled()
|
||||
expect($connection.get()?.connectionId).toBe('local')
|
||||
expect($pendingConnectionId.get()).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a timed-out activation cannot publish the target after it eventually settles', async () => {
|
||||
it('a dial that never answers times out visibly, cannot publish later, and the gateway can be retried', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
@@ -879,11 +484,9 @@ describe('selectConnection', () => {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
ensureGatewayAgent.mockImplementationOnce(async (connectionId, _profile, options) => {
|
||||
options?.beforeActivate?.()
|
||||
await activation.promise
|
||||
|
||||
// Mirrors the real activation door: cancellation ownership is checked
|
||||
// immediately before publishing after the async activation work.
|
||||
// Mirrors the real door: ownership is checked right before publishing.
|
||||
if (options?.signal?.aborted) {
|
||||
return
|
||||
}
|
||||
@@ -901,19 +504,48 @@ describe('selectConnection', () => {
|
||||
(error: Error) => error.message
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await vi.advanceTimersByTimeAsync(SOURCE_SWITCH_DIAL_TIMEOUT_MS)
|
||||
|
||||
expect(await outcome).toMatch(/Timed out activating "Homelab"/)
|
||||
expect($connection.get()?.connectionId).toBe('local')
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
expect(await outcome).toMatch(/Timed out connecting to "Homelab"/)
|
||||
expect($pendingConnectionId.get()).toBeNull()
|
||||
|
||||
activation.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect($connection.get()?.connectionId).toBe('local')
|
||||
expect(beginGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect(endGatewaySwitch).toHaveBeenCalledTimes(1)
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
|
||||
await selectConnection('homelab')
|
||||
expect($connection.get()?.connectionId).toBe('homelab')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('an activation that published but never settled counts as landed', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
setConnectionsRegistry(registry)
|
||||
$connection.set({ connectionId: 'local', mode: 'local' })
|
||||
// The socket activates and publishes; only a trailing descriptor resync stalls.
|
||||
ensureGatewayAgent.mockImplementationOnce((connectionId, _profile, options) => {
|
||||
options?.beforeActivate?.()
|
||||
$connection.set({
|
||||
connectionId: connectionId ?? undefined,
|
||||
mode: 'remote',
|
||||
profile: 'default',
|
||||
registryScoped: true
|
||||
})
|
||||
|
||||
return new Promise<void>(() => undefined)
|
||||
})
|
||||
|
||||
const attempt = selectConnection('homelab')
|
||||
await vi.advanceTimersByTimeAsync(SOURCE_SWITCH_DIAL_TIMEOUT_MS)
|
||||
await attempt
|
||||
|
||||
expect($connection.get()?.connectionId).toBe('homelab')
|
||||
expect(setLastUsed).toHaveBeenCalledWith('homelab')
|
||||
expect(requestFreshSession).toHaveBeenCalledTimes(1)
|
||||
expect($pendingConnectionId.get()).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -930,13 +562,11 @@ describe('selectConnection', () => {
|
||||
const restoring = initializeConnectionsRegistry()
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(openGatewayAgent).not.toHaveBeenCalled()
|
||||
expect(ensureGatewayAgent).not.toHaveBeenCalled()
|
||||
|
||||
$connection.set({ connectionId: 'homelab', mode: 'remote' })
|
||||
await restoring
|
||||
|
||||
expect(openGatewayAgent).not.toHaveBeenCalled()
|
||||
expect(ensureGatewayAgent).not.toHaveBeenCalled()
|
||||
expect(setLastUsed).toHaveBeenCalledWith('homelab')
|
||||
expect($showAllProfiles.get()).toBe(true)
|
||||
@@ -1004,14 +634,13 @@ describe('selectConnection', () => {
|
||||
|
||||
// A later resync republishes a profile-less primary descriptor (the
|
||||
// startHermes shape). The remembered pair is the authority for "what was
|
||||
// last used here" — switching away and back must still restore 'mac',
|
||||
// and the commit must not die in targetIsActive() on the descriptor gap.
|
||||
// last used here" — picking away and back must still restore 'mac', and
|
||||
// the pick must not die in targetIsActive() on the descriptor gap.
|
||||
await selectConnection('homelab')
|
||||
expect(ensureGatewayAgent).toHaveBeenLastCalledWith('homelab', 'default', expect.anything())
|
||||
|
||||
await selectConnection('local')
|
||||
|
||||
expect(openGatewayAgent).toHaveBeenLastCalledWith('local', 'mac')
|
||||
expect(ensureGatewayAgent).toHaveBeenLastCalledWith('local', 'mac', expect.anything())
|
||||
expect($newChatProfile.get()).toBe('mac')
|
||||
})
|
||||
@@ -1027,7 +656,6 @@ describe('selectConnection', () => {
|
||||
await initializeConnectionsRegistry()
|
||||
|
||||
expect(ensureGatewayAgent).not.toHaveBeenCalled()
|
||||
expect(wipeSessionListsForGatewaySwitch).not.toHaveBeenCalled()
|
||||
expect($connection.get()?.mode).toBe('remote')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { getProfiles } from '@/api/profiles'
|
||||
import type { DesktopConnectionsRegistry } from '@/global'
|
||||
import { traceIdentityChange } from '@/lib/identity-trace'
|
||||
import { invalidateProfileScopedQueries } from '@/lib/query-client'
|
||||
@@ -13,12 +12,7 @@ import {
|
||||
} from '@/lib/with-timeout'
|
||||
import { $connectionsRegistry } from '@/store/connection-registry-state'
|
||||
import { $defaultProfileRoute, refreshDefaultProfile } from '@/store/default-profile'
|
||||
import {
|
||||
beginGatewaySwitch,
|
||||
endGatewaySwitch,
|
||||
type GatewaySwitchToken,
|
||||
recoverActiveSourceAfterFailedGatewaySwitch
|
||||
} from '@/store/gateway-switch'
|
||||
import { supersedeGatewaySwitch } from '@/store/gateway-switch'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$freshSessionRequest,
|
||||
@@ -33,22 +27,23 @@ import {
|
||||
refreshActiveProfile,
|
||||
requestFreshSession
|
||||
} from '@/store/profile'
|
||||
import { $activeSessionId, $connection, $selectedStoredSessionId } from '@/store/session'
|
||||
import { exitProjectScope } from '@/store/project-scope'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$connection,
|
||||
$selectedStoredSessionId,
|
||||
setActiveSessionId,
|
||||
setSelectedStoredSessionId
|
||||
} from '@/store/session'
|
||||
import { isPeerInstanceWindow, windowProfileOverride } from '@/store/windows'
|
||||
|
||||
const LAST_PROFILE_STORAGE_KEY = 'hermes.desktop.lastProfileByConnection'
|
||||
|
||||
// Every await of a source switch is bounded. A wedged spawn, ticket mint,
|
||||
// Every await of a gateway pick is bounded. A wedged spawn, ticket mint,
|
||||
// handshake or IPC (the #93454 class) must surface as a failed click — not a
|
||||
// spinner that also swallows every later click on the same source, and never
|
||||
// a barrier left up or a wipe left unpainted.
|
||||
//
|
||||
// The dial and the commit are different work and so carry different budgets.
|
||||
// The commit is local (sever the old bindings, activate, publish) and stays at
|
||||
// the reconnect-class 20 s. The dial is the main process's whole remote
|
||||
// bring-up chain, which the renderer cannot time from here — see
|
||||
// spinner that also swallows every later click on the same gateway. A cold
|
||||
// dial is the main process's whole remote bring-up chain; see
|
||||
// SOURCE_SWITCH_DIAL_TIMEOUT_MS in lib/with-timeout.ts for its composition.
|
||||
const SWITCH_COMMIT_TIMEOUT_MS = 20_000
|
||||
const SWITCH_REMEMBER_TIMEOUT_MS = 5_000
|
||||
// Matches the primary spawn budget: a healthy cold boot publishes well within
|
||||
// this; anything longer means the primary is not coming and the registry
|
||||
@@ -133,19 +128,19 @@ export function _resetConnectionsForTests(): void {
|
||||
$pendingConnectionId.set(null)
|
||||
}
|
||||
|
||||
// A connection switch is a new-chat intent on THAT source: keep the registry
|
||||
// A gateway pick is a new-chat intent on THAT gateway: keep the registry
|
||||
// identity with the profile so the next create names local::x / <source>::x
|
||||
// exactly, never a bare profile string. An older explicit agent route must not
|
||||
// override the selected source. The silent boot restore is not a user choice,
|
||||
// so it keeps a route the user pinned (gateway-group +) while it was in flight.
|
||||
function rehomeNewChatDraft(profile: string, keepExplicitRoute = false): void {
|
||||
function rehomeNewChatDraft(profile: string, keepExplicitRoute = false, connectionId?: string): void {
|
||||
$newChatProfile.set(profile)
|
||||
|
||||
if (!keepExplicitRoute) {
|
||||
$newChatRoute.set(null)
|
||||
}
|
||||
|
||||
captureNewChatSource()
|
||||
captureNewChatSource(connectionId)
|
||||
requestFreshSession()
|
||||
}
|
||||
|
||||
@@ -330,29 +325,21 @@ export async function initializeConnectionsRegistry(): Promise<DesktopConnection
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-home Sessions to one registered source, restoring that source's last
|
||||
* profile. Only the selected source is dialed; merely rendering the switcher
|
||||
* never probes or opens remote gateways.
|
||||
* Re-scope Sessions to one registered gateway, landing on the profile the
|
||||
* caller names or the one last used there.
|
||||
*
|
||||
* Two phases, same commit contract as a Settings → Gateway apply (softSwitch):
|
||||
* 1. Dial the target — and for OAuth remotes prove a protected REST read —
|
||||
* WITHOUT activating it. The previous source stays fully bound and
|
||||
* painted, so a dead target loses nothing.
|
||||
* 2. Commit: beginGatewaySwitch() — barrier up, machine-context reset,
|
||||
* session bindings wiped — then activate the already-open socket. The
|
||||
* wipe runs inside the activation's serialized section, synchronously
|
||||
* before the publication, so no route/session effect can observe the new
|
||||
* source while $activeSessionId still names the previous backend's
|
||||
* runtime. Activating first and wiping after (across an IPC round-trip)
|
||||
* is exactly how that id leaked to the new backend and came back as
|
||||
* "session not found" (#93937).
|
||||
* Every registered gateway is already connected (store/gateway-persistence),
|
||||
* so this is the same live activation a profile pick or a cross-gateway resume
|
||||
* makes: no session wipe, no barrier, no confirm. A gateway that is down fails
|
||||
* at the open, before anything in front is touched. Sessions on the gateway left
|
||||
* behind keep streaming on their own sockets, and the lists re-read for the
|
||||
* new scope because the active connection id moved. Session RPCs route by
|
||||
* their owner (connection, profile), never by the gateway in front, so nothing
|
||||
* bound to the previous gateway has to be severed first.
|
||||
*
|
||||
* Overlap and stalls: clicks can supersede a switch at any point. A switch
|
||||
* superseded while queued behind another activation declines its commit —
|
||||
* no wipe, no activation — so the winner's wipe is always the one that
|
||||
* precedes the final publication, and the barrier is owned by the latest
|
||||
* switch. Every await is bounded; a commit that stalls after the wipe lowers
|
||||
* the barrier and repaints the source that is still active.
|
||||
* Overlap: a newer click supersedes an older one at any point. A superseded
|
||||
* activation still queued behind another declines (beforeActivate), so the
|
||||
* user never flips through it; one that times out loses its right to publish.
|
||||
*/
|
||||
export interface SelectConnectionOptions {
|
||||
/** Land on this profile of the target source instead of the one last used
|
||||
@@ -452,158 +439,97 @@ export async function selectConnection(connectionId: string, options: SelectConn
|
||||
}
|
||||
|
||||
const revision = ++switchRevision
|
||||
// A draft the user starts while this switch is in flight (a gateway group's
|
||||
// "+", a profile pick) is newer than the switch; the late re-home must not
|
||||
// take it over. switchRevision only sees other switches.
|
||||
// A draft the user starts while this pick is in flight (a gateway group's
|
||||
// "+", a profile pick) is newer than it; the late re-home must not take it over.
|
||||
const draftIntent = currentNewChatIntent()
|
||||
const activation = new AbortController()
|
||||
let rehomed = false
|
||||
pendingTarget = targetKey
|
||||
$pendingConnectionId.set(connectionId)
|
||||
// Set by the commit hook once THIS switch has wiped — i.e. it owns the
|
||||
// barrier and, if the commit then fails, owes the still-active source a
|
||||
// repaint. Null while queued, or if it stepped aside before its turn.
|
||||
let token = null as GatewaySwitchToken | null
|
||||
|
||||
// Runs inside the activation's serialized section, synchronously right
|
||||
// before the target is published, so the center is already a fresh draft on
|
||||
// the target when route effects first see it (#93937). A superseded pick
|
||||
// declines here: no draft, no activation, so the user never flips through it.
|
||||
const beforeActivate = () => {
|
||||
if (revision !== switchRevision) {
|
||||
return false
|
||||
}
|
||||
|
||||
supersedeGatewaySwitch()
|
||||
|
||||
if (!restoreOnBoot) {
|
||||
$showAllProfiles.set(false)
|
||||
}
|
||||
|
||||
if (currentNewChatIntent() === draftIntent) {
|
||||
// The open runtime belongs to the gateway being left; unbind it now, not
|
||||
// when the fresh draft renders, so nothing pairs it with the target.
|
||||
setActiveSessionId(null)
|
||||
setSelectedStoredSessionId(null)
|
||||
rehomeNewChatDraft(targetProfile, restoreOnBoot, connectionId)
|
||||
rehomed = true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
// Phase 1 — open the target's socket; the active route is untouched.
|
||||
// Always use the explicit registry route. `local` must mean This device,
|
||||
// and a registry primary can differ from a legacy per-profile override.
|
||||
await withTimeout(
|
||||
openGatewayAgent(connectionId, targetProfile),
|
||||
SOURCE_SWITCH_DIAL_TIMEOUT_MS,
|
||||
`Timed out connecting to "${targetConnection.label}".`
|
||||
)
|
||||
const timeoutMessage = `Timed out connecting to "${targetConnection.label}".`
|
||||
// Open the target WITHOUT activating it, so a dead gateway fails here and
|
||||
// the one in front loses nothing — not even the draft. Every registered
|
||||
// gateway is normally connected already, so this resolves at once.
|
||||
await withTimeout(openGatewayAgent(connectionId, targetProfile), SOURCE_SWITCH_DIAL_TIMEOUT_MS, timeoutMessage)
|
||||
|
||||
// A newer click owns the switch from here on. The superseded dial never
|
||||
// activates, so the user doesn't flip through it on the way to the source
|
||||
// they picked last; its socket stays warm for that click or idles out.
|
||||
if (revision !== switchRevision) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
targetConnection.authMode === 'oauth' &&
|
||||
(targetConnection.kind === 'remote' || targetConnection.kind === 'cloud')
|
||||
) {
|
||||
// Retained sockets can outlive cookie/native OAuth REST auth. Prove the
|
||||
// cheapest protected read the target always serves before wiping. Keep
|
||||
// the exact failure for caller UX (network failures are not sign-in errors).
|
||||
await withTimeout(
|
||||
getProfiles({ connectionId, profile: targetProfile }),
|
||||
SOURCE_SWITCH_DIAL_TIMEOUT_MS,
|
||||
`Timed out connecting to "${targetConnection.label}".`
|
||||
)
|
||||
|
||||
if (revision !== switchRevision) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2 — commit. The hook runs inside the activation's serialized
|
||||
// section, right before the socket is activated: sever the previous
|
||||
// backend's bindings, then publish, with nothing in between. A click that
|
||||
// superseded this switch while it was queued makes the hook decline —
|
||||
// neither wipe nor activation — so the user never flips through it.
|
||||
const activationController = new AbortController()
|
||||
let markActivationStarted: () => void = () => undefined
|
||||
|
||||
const activationStarted = new Promise<void>(resolve => {
|
||||
markActivationStarted = resolve
|
||||
})
|
||||
|
||||
try {
|
||||
try {
|
||||
const activation = ensureGatewayAgent(connectionId, targetProfile, {
|
||||
signal: activationController.signal,
|
||||
beforeActivate: () => {
|
||||
if (revision !== switchRevision) {
|
||||
return false
|
||||
}
|
||||
|
||||
token = beginGatewaySwitch()
|
||||
markActivationStarted()
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const timedActivation = activationStarted.then(() =>
|
||||
withTimeout(
|
||||
activation,
|
||||
SWITCH_COMMIT_TIMEOUT_MS,
|
||||
`Timed out activating "${targetConnection.label}".`,
|
||||
error => {
|
||||
// withTimeout does not cancel its input. Every timed-out owner
|
||||
// loses future activation/publication rights, even when low-level
|
||||
// activation already published the target and the commit remains
|
||||
// fail-open. The shared activation signal suppresses any trailing
|
||||
// descriptor/profile publication when stale work later settles.
|
||||
activationController.abort(error)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Queue time belongs to the profile-store mutex. Start the bounded
|
||||
// commit window only once beforeActivate grants this request its turn.
|
||||
await Promise.race([activation, timedActivation])
|
||||
} catch (error) {
|
||||
// The socket is activated and its descriptor published synchronously;
|
||||
// only best-effort descriptor resync trails it. A commit that timed out
|
||||
// AFTER the new source became active has landed, so keep it fail-open;
|
||||
// the timeout signal still revokes all trailing publication rights.
|
||||
if (!isTimeoutError(error) || !targetIsActive()) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (revision !== switchRevision) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!targetIsActive()) {
|
||||
throw new Error(`Connection "${targetConnection.label}" did not become active.`)
|
||||
}
|
||||
} finally {
|
||||
// Lower the barrier the moment the commit settles — before the
|
||||
// bookkeeping awaits below — but only if this switch still owns it.
|
||||
if (token !== null) {
|
||||
endGatewaySwitch(token)
|
||||
await withTimeout(
|
||||
ensureGatewayAgent(connectionId, targetProfile, { beforeActivate, signal: activation.signal }),
|
||||
SOURCE_SWITCH_DIAL_TIMEOUT_MS,
|
||||
timeoutMessage,
|
||||
error => activation.abort(error)
|
||||
)
|
||||
} catch (error) {
|
||||
// A timeout after the target already became active has landed.
|
||||
if (!isTimeoutError(error) || !targetIsActive()) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// A newer click owns the final refresh. Serialized gateway activation
|
||||
// already makes the latest source win; this guard also prevents an older
|
||||
// request from repainting its profile list after that newer activation.
|
||||
if (revision !== switchRevision) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!targetIsActive()) {
|
||||
throw new Error(`Connection "${targetConnection.label}" did not become active.`)
|
||||
}
|
||||
|
||||
// Project ids name rows in one backend's projects.db.
|
||||
if (currentConnectionId !== connectionId) {
|
||||
exitProjectScope()
|
||||
}
|
||||
|
||||
await rememberConnection(connectionId)
|
||||
|
||||
if (revision === switchRevision) {
|
||||
await rememberConnection(connectionId)
|
||||
|
||||
// Remembering crosses IPC too; a newer click may now own the draft.
|
||||
if (revision !== switchRevision) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!restoreOnBoot) {
|
||||
$showAllProfiles.set(false)
|
||||
}
|
||||
|
||||
if (currentNewChatIntent() === draftIntent) {
|
||||
rehomeNewChatDraft(targetProfile, restoreOnBoot)
|
||||
}
|
||||
|
||||
await refreshActiveProfile()
|
||||
}
|
||||
} catch (error) {
|
||||
if (revision === switchRevision) {
|
||||
if (token !== null) {
|
||||
// This switch wiped for a commit that never landed. The previous
|
||||
// source is still the active one, and nothing reactive re-pulls its
|
||||
// lists (no scope moved): repaint it and land on a fresh draft there,
|
||||
// matching what a failed Settings apply leaves behind.
|
||||
recoverActiveSourceAfterFailedGatewaySwitch(token)
|
||||
requestFreshSession()
|
||||
}
|
||||
|
||||
throw error
|
||||
// A newer pick owns the outcome; this one's failure is moot.
|
||||
if (revision !== switchRevision) {
|
||||
return
|
||||
}
|
||||
|
||||
// The target never became active: point the draft back at the gateway
|
||||
// still in front so the next message cannot go to the one that failed.
|
||||
if (rehomed) {
|
||||
rehomeNewChatDraft(currentProfile)
|
||||
}
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
if (revision === switchRevision) {
|
||||
pendingTarget = null
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DesktopConnectionsRegistry } from '@/global'
|
||||
|
||||
const released: string[] = []
|
||||
const retainGatewayForRelay = vi.fn((connectionId: string) => () => released.push(connectionId))
|
||||
const openGatewayForAgent = vi.fn(async (_connectionId: string, _profile: string) => undefined)
|
||||
const live = new Set<string>()
|
||||
|
||||
vi.mock('@/store/gateway', () => ({
|
||||
liveSecondaryConnectionIds: () => new Set(live),
|
||||
openGatewayForAgent,
|
||||
retainGatewayForRelay
|
||||
}))
|
||||
|
||||
const { $connectionsRegistry } = await import('@/store/connection-registry-state')
|
||||
const { $primaryGatewayRoute } = await import('@/store/gateway-primary-route')
|
||||
const { startPersistentGateways } = await import('./gateway-persistence')
|
||||
|
||||
const registry = (ids: string[]): DesktopConnectionsRegistry => ({
|
||||
connections: ids.map(id => ({
|
||||
id,
|
||||
kind: id === 'local' ? 'local' : 'remote',
|
||||
label: id,
|
||||
tokenPreview: null,
|
||||
tokenSet: false
|
||||
})),
|
||||
primary: 'local',
|
||||
secureTokenStorage: true,
|
||||
version: 2
|
||||
})
|
||||
|
||||
let stop: () => void = () => undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
released.length = 0
|
||||
live.clear()
|
||||
retainGatewayForRelay.mockClear()
|
||||
openGatewayForAgent.mockClear()
|
||||
$primaryGatewayRoute.set({ connectionId: 'local', mode: 'local' })
|
||||
$connectionsRegistry.set(registry(['local', 'homelab', 'vps']))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stop()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps every registered gateway the primary socket does not serve connected, and lets a removed one go', () => {
|
||||
stop = startPersistentGateways()
|
||||
|
||||
expect(retainGatewayForRelay.mock.calls.map(call => call[0])).toEqual(['homelab', 'vps'])
|
||||
expect(openGatewayForAgent.mock.calls).toEqual([
|
||||
['homelab', 'default'],
|
||||
['vps', 'default']
|
||||
])
|
||||
|
||||
// A dropped socket is redialed on the next tick; a live one is left alone.
|
||||
live.add('homelab')
|
||||
openGatewayForAgent.mockClear()
|
||||
vi.advanceTimersByTime(30_000)
|
||||
expect(openGatewayForAgent.mock.calls).toEqual([['vps', 'default']])
|
||||
|
||||
$connectionsRegistry.set(registry(['local', 'homelab']))
|
||||
expect(released).toEqual(['vps'])
|
||||
expect(retainGatewayForRelay).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('pins This device on its own socket when a remote gateway is the primary', () => {
|
||||
$primaryGatewayRoute.set({ connectionId: 'homelab', mode: 'remote' })
|
||||
stop = startPersistentGateways()
|
||||
|
||||
expect(retainGatewayForRelay.mock.calls).toEqual([
|
||||
['local', 'default', { includeLocal: true }],
|
||||
['vps', 'default', { includeLocal: true }]
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { LOCAL_CONNECTION_ID } from '@hermes/shared'
|
||||
|
||||
import type { DesktopConnectionsRegistry, DesktopRegistryConnection } from '@/global'
|
||||
import { $connectionsRegistry } from '@/store/connection-registry-state'
|
||||
import { liveSecondaryConnectionIds, openGatewayForAgent, retainGatewayForRelay } from '@/store/gateway'
|
||||
import { $primaryGatewayRoute, type PrimaryGatewayRoute } from '@/store/gateway-primary-route'
|
||||
|
||||
// Every registered gateway stays connected for as long as the window lives: its
|
||||
// home socket is dialed at startup, pinned against the idle pruner, and redialed
|
||||
// when it drops. Picking a gateway or opening one of its sessions then costs no
|
||||
// dial, and the unified Sessions list (Electron splices every pooled backend's
|
||||
// rows) shows every gateway at once instead of only the one in front.
|
||||
const HOME_PROFILE = 'default'
|
||||
const REDIAL_INTERVAL_MS = 30_000
|
||||
|
||||
/** The window's primary socket already holds this gateway open; a second socket
|
||||
* to the same backend would duplicate its event stream. */
|
||||
function servedByPrimary(connection: DesktopRegistryConnection, primary: PrimaryGatewayRoute): boolean {
|
||||
if (primary.connectionId) {
|
||||
return primary.connectionId === connection.id
|
||||
}
|
||||
|
||||
return connection.id === LOCAL_CONNECTION_ID && primary.mode !== 'remote'
|
||||
}
|
||||
|
||||
/** The registry connections that need their own pinned socket. */
|
||||
export function persistentGatewayIds(
|
||||
registry: DesktopConnectionsRegistry | null,
|
||||
primary: PrimaryGatewayRoute
|
||||
): string[] {
|
||||
return (registry?.connections ?? []).filter(connection => !servedByPrimary(connection, primary)).map(c => c.id)
|
||||
}
|
||||
|
||||
const connectedListeners = new Set<() => void>()
|
||||
|
||||
/** Fires each time a kept gateway (re)connects, so lists drawn before it was
|
||||
* reachable re-pull its rows. Returns the unsubscribe. */
|
||||
export function onKeptGatewayConnected(handler: () => void): () => void {
|
||||
connectedListeners.add(handler)
|
||||
|
||||
return () => connectedListeners.delete(handler)
|
||||
}
|
||||
|
||||
function dial(connectionId: string): void {
|
||||
// Background priority: a gateway that is down or needs sign-in fails fast and
|
||||
// stays throttled; the next tick (or a focus/online nudge) tries again.
|
||||
void openGatewayForAgent(connectionId, HOME_PROFILE).then(
|
||||
() => connectedListeners.forEach(handler => handler()),
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** Start keeping every registered gateway connected. Returns the teardown. */
|
||||
export function startPersistentGateways(): () => void {
|
||||
const pins = new Map<string, () => void>()
|
||||
|
||||
const reconcile = () => {
|
||||
const wanted = new Set(persistentGatewayIds($connectionsRegistry.get(), $primaryGatewayRoute.get()))
|
||||
|
||||
for (const [connectionId, release] of pins) {
|
||||
if (!wanted.has(connectionId)) {
|
||||
pins.delete(connectionId)
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
const live = liveSecondaryConnectionIds()
|
||||
|
||||
for (const connectionId of wanted) {
|
||||
if (!pins.has(connectionId)) {
|
||||
pins.set(connectionId, retainGatewayForRelay(connectionId, HOME_PROFILE, { includeLocal: true }))
|
||||
}
|
||||
|
||||
if (!live.has(connectionId)) {
|
||||
dial(connectionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const offRegistry = $connectionsRegistry.subscribe(reconcile)
|
||||
const offPrimary = $primaryGatewayRoute.listen(reconcile)
|
||||
const timer = setInterval(reconcile, REDIAL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
offRegistry()
|
||||
offPrimary()
|
||||
|
||||
for (const release of pins.values()) {
|
||||
release()
|
||||
}
|
||||
|
||||
pins.clear()
|
||||
}
|
||||
}
|
||||
|
||||
let stopKeeping: (() => void) | null = null
|
||||
|
||||
/** The window-lifetime keeper: starts once, later calls are no-ops. */
|
||||
export function keepGatewaysConnected(): void {
|
||||
stopKeeping ??= startPersistentGateways()
|
||||
}
|
||||
|
||||
import.meta.hot?.dispose(() => {
|
||||
stopKeeping?.()
|
||||
stopKeeping = null
|
||||
})
|
||||
@@ -39,9 +39,10 @@ import { clearAllSessionTodos } from '@/store/todos'
|
||||
import { clearTranscriptTailPaging } from '@/store/transcript-tail'
|
||||
import { clearTranscriptTails } from '@/store/transcript-tail-cache'
|
||||
|
||||
// True while a connection switch is mid-flight — a Settings → Gateway apply
|
||||
// (wipe → re-dial, use-gateway-boot softSwitch) or a Sessions-switcher source
|
||||
// change (store/connections selectConnection). Lets the boot hook suppress the
|
||||
// True while a Settings → Gateway connection-mode apply is mid-flight (wipe →
|
||||
// re-dial, use-gateway-boot softSwitch). Picking a registered gateway in
|
||||
// Sessions is not a switch: every gateway stays connected and the pick is a
|
||||
// live activation (store/connections selectConnection). Lets the boot hook suppress the
|
||||
// backend-exit toast, keeps the cold-boot CONNECTING overlay from resurrecting
|
||||
// when startHermes re-emits boot progress, and tells the resume path that a
|
||||
// "session not found" mid-switch means "retry once things settle", not "gone".
|
||||
@@ -84,14 +85,12 @@ export function registerGatewaySwitchLifecycle(lifecycle: GatewaySwitchLifecycle
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit point of every connection switch: raise the barrier and sever every
|
||||
* binding to the outgoing backend in ONE synchronous step. Both switch doors —
|
||||
* Settings apply (softSwitch) and the Sessions switcher (selectConnection) —
|
||||
* must call this BEFORE the next gateway is activated or its descriptor
|
||||
* published. The sidebar door used to activate first and wipe afterwards
|
||||
* (across an IPC round-trip), so route/session effects saw the new source while
|
||||
* $activeSessionId still named the previous backend's runtime and sent that id
|
||||
* to a backend that had never minted it — "session not found" (#93937).
|
||||
* Commit point of a connection-mode apply: raise the barrier and sever every
|
||||
* binding to the outgoing backend in ONE synchronous step, BEFORE the next
|
||||
* gateway is activated or its descriptor published. Activating first and
|
||||
* wiping after (across an IPC round-trip) let route/session effects see the new
|
||||
* source while $activeSessionId still named the previous backend's runtime —
|
||||
* "session not found" (#93937).
|
||||
*/
|
||||
export function beginGatewaySwitch(): GatewaySwitchToken {
|
||||
const token = ++latestSwitchToken
|
||||
@@ -128,6 +127,17 @@ export function beginGatewaySwitch(): GatewaySwitchToken {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A gateway pick made while a Settings apply is mid-flight wins: the apply's
|
||||
* token stops owning any later publication, and its barrier comes down.
|
||||
*/
|
||||
export function supersedeGatewaySwitch(): void {
|
||||
if ($gatewaySwitching.get()) {
|
||||
latestSwitchToken += 1
|
||||
endGatewaySwitch()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower the barrier once the switch that owns it has committed (or failed).
|
||||
* Switches overlap — a click can supersede one that is mid-commit — and the
|
||||
|
||||
@@ -1424,19 +1424,19 @@ function reopenAfterRedial(entry: Secondary, wasActive: boolean): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the pooled socket for one relay route open across drain ticks. Returns
|
||||
* a once-only release. Local routes (null/empty or explicit `local` source)
|
||||
* are deliberately EXEMPT and get a no-op release: a relay pin would keep a
|
||||
* local socket open past the point where retireLocalProfileGateways means to
|
||||
* drop it (see that note). Local relay traffic is
|
||||
* either the primary socket (no churn) or a short-lived local dial — never
|
||||
* the remote reconnect flood this retention exists to stop.
|
||||
* Pin one route's socket open across drain ticks (bot relay; each gateway's home socket, gateway-persistence).
|
||||
* Returns a once-only release. Null, and `local` unless `includeLocal`, get a no-op: a relay pin would outlive
|
||||
* retireLocalProfileGateways' drop, and local relay traffic never floods reconnects.
|
||||
*/
|
||||
export function retainGatewayForRelay(connectionId: null | string, profile: string): () => void {
|
||||
export function retainGatewayForRelay(
|
||||
connectionId: null | string,
|
||||
profile: string,
|
||||
{ includeLocal = false }: { includeLocal?: boolean } = {}
|
||||
): () => void {
|
||||
const key = normKey(profile)
|
||||
const connection = String(connectionId ?? '').trim()
|
||||
|
||||
if (!connection || connection === 'local') {
|
||||
if (!connection || (connection === 'local' && !includeLocal)) {
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -2499,9 +2499,6 @@
|
||||
"profiles.fleet.installDeviceTitle",
|
||||
"profiles.fleet.localDevice",
|
||||
"profiles.fleet.onGateway",
|
||||
"profiles.fleet.switchDeviceConfirm",
|
||||
"profiles.fleet.switchDeviceDesc",
|
||||
"profiles.fleet.switchDeviceTitle",
|
||||
"profiles.fleet.switchTo",
|
||||
"profiles.importProfile",
|
||||
"profiles.imported",
|
||||
|
||||
@@ -6,9 +6,10 @@ sidebar_position: 5
|
||||
|
||||
Register every Hermes backend you own — the local runtime, remote gateways on
|
||||
your LAN or VPS, SSH hosts, and Hermes Cloud instances — in one desktop app,
|
||||
and use the agents on all of them side by side. Connections are persistent:
|
||||
each registered gateway dials its own backends and WebSockets on demand, and
|
||||
background agents keep streaming while you look at another gateway.
|
||||
and use the agents on all of them side by side. Every registered gateway is
|
||||
connected all the time: Desktop dials each one when it starts, keeps its
|
||||
connection open, and redials it if it drops, so there is no switching cost and
|
||||
background agents keep streaming whichever gateway you are looking at.
|
||||
|
||||
This is the desktop-side complement to
|
||||
[Running Many Gateways at Once](./multi-profile-gateways.md): that page is
|
||||
@@ -76,10 +77,11 @@ Rules worth knowing:
|
||||
the top of the Gateways page — the **Hermes Cloud** kind in the add-connection
|
||||
editor points you there.
|
||||
|
||||
Switch gateways from the **Sessions** sidebar. Profiles, chats, messaging, and
|
||||
cron stay scoped to that gateway; the app-managed window backend is still chosen
|
||||
by the connection-mode controls above. **Primary** is the registry fallback and
|
||||
does not switch the current workspace.
|
||||
Every registered gateway stays connected, and the **Sessions** sidebar can
|
||||
show all of them at once. Profiles, chats, messaging, and cron stay with their
|
||||
own gateway; the app-managed window backend is still chosen by the
|
||||
connection-mode controls above. **Primary** is the registry fallback and does
|
||||
not change the current workspace.
|
||||
|
||||
## Organizing session groups
|
||||
|
||||
@@ -179,10 +181,10 @@ The union roster is what multi-gateway surfaces (and the built-in
|
||||
as **`@name-device`** — `research` on your Homelab renders as
|
||||
`@research-homelab`, while a profile unique across all gateways keeps its
|
||||
bare name.
|
||||
- Enumeration is eager but sockets are lazy: the app lists agents over REST
|
||||
without dialing every gateway's WebSocket. An unreachable gateway reports
|
||||
per-row instead of breaking the roster; SSH connections stay connect-on-demand
|
||||
until you first open an agent on them (no surprise tunnels).
|
||||
- Every registered gateway, SSH included, connects when Desktop starts and
|
||||
stays connected: its default profile holds one open connection for the life
|
||||
of the window and is redialed if it drops. An unreachable gateway reports
|
||||
per-row instead of breaking the roster, and Desktop keeps retrying it.
|
||||
- Opening an agent dials **its own gateway** — chats, sessions, and memory
|
||||
live on the machine that owns the profile, exactly as if you were using
|
||||
that instance directly.
|
||||
@@ -204,7 +206,7 @@ missing. Saved session ownership still takes precedence, and deleting or
|
||||
renaming that local profile clears this temporary route rather than reconnecting
|
||||
an obsolete backend.
|
||||
|
||||
### Switching and scoping
|
||||
### Gateways, profiles and scope
|
||||
|
||||
The sidebar foot follows one hierarchy: **gateway → profile → sessions**.
|
||||
Gateways are machines or hosted backends; profiles are isolated Hermes agents
|
||||
@@ -216,7 +218,9 @@ that live on one gateway.
|
||||
cloud, network, or terminal icon identifies the connection type; profile
|
||||
avatars remain a separate control after the divider. The same selector scales
|
||||
from two gateways to a larger fleet without turning backends into profile-like
|
||||
glyphs or crowding profile actions out of the rail.
|
||||
glyphs or crowding profile actions out of the rail. Picking a gateway is
|
||||
instant: it is already connected, nothing is wiped, there is no confirmation,
|
||||
and sessions on the gateway you leave keep running.
|
||||
- Selecting a gateway restores the last profile used there. The home pill
|
||||
returns to its default profile and the layers pill shows **All profiles on
|
||||
this gateway**. **Cmd/Ctrl+1–9** continue to switch profiles within the
|
||||
@@ -224,19 +228,20 @@ that live on one gateway.
|
||||
- With several gateways the profile rail is a **fleet rail**: every registered
|
||||
gateway's profiles sit on the one strip, each group headed by that gateway's
|
||||
kind glyph (device, network, terminal, cloud) — the same glyph the gateway
|
||||
selector uses. The active gateway's squares look exactly as they do on a
|
||||
single-gateway Desktop; the other gateways' squares are dimmed ("at rest").
|
||||
Hovering an at-rest square names its machine (`omer · This device`), so two
|
||||
same-named profiles on different machines never read alike.
|
||||
- Clicking an at-rest square performs the same switch as the gateway selector,
|
||||
landing on that exact `(gateway, profile)`: the square spins while the
|
||||
target is dialed, the previous gateway stays painted until the target
|
||||
answers, and a dead target fails the click with a message rather than
|
||||
leaving the window half-switched. Groups keep registry order whichever
|
||||
gateway is active, so a square never moves under the pointer that clicked
|
||||
it. Right-click on an at-rest square offers **Switch to**, **Color**,
|
||||
**Rename**, **Edit SOUL.md** and **Delete**, all executed on the square's
|
||||
own gateway; the delete confirmation names the machine.
|
||||
selector uses. Only the selected profile is highlighted; every other square,
|
||||
on any gateway, has the same unselected look. Hovering another gateway's
|
||||
square names its machine (`omer · This device`), so two same-named profiles
|
||||
on different machines never read alike.
|
||||
- Clicking another gateway's square is a profile pick on that gateway, landing
|
||||
on that exact `(gateway, profile)` with a fresh draft there. A profile whose
|
||||
backend is not open yet spins while it is dialed; a dead target fails the
|
||||
click with a message and leaves everything you had in front untouched.
|
||||
Groups keep registry order whichever gateway is active, so a square never
|
||||
moves under the pointer that clicked it. Right-click on another gateway's
|
||||
square offers **Open**, **Color**, **Rename**, **Edit SOUL.md** and
|
||||
**Delete**, all executed on the square's own gateway; the delete
|
||||
confirmation names the machine. Picking **This device** asks for
|
||||
confirmation only when Hermes is not installed on this computer yet.
|
||||
- A gateway the last enumeration could not reach keeps its squares, marked
|
||||
with an amber dot on its glyph — a sleeping box is still yours. Two
|
||||
registrations of one backend collapse to a single group. Past thirteen
|
||||
@@ -261,11 +266,11 @@ that live on one gateway.
|
||||
bar hidden, and keeps it current when gateways are saved or removed. Opening
|
||||
Settings is not required. In Simple mode, the profile rail stays available
|
||||
when more than one gateway is registered, even with only a default profile.
|
||||
Loading the registry does not connect every gateway; there is no periodic
|
||||
fleet polling.
|
||||
- Hovering an agent pre-warms its backend so the switch doesn't pay a cold
|
||||
boot. SSH agents are the exception: hovering never dials the tunnel or
|
||||
starts a remote backend — only opening one does.
|
||||
Every gateway in the registry is connected from startup; adding one connects
|
||||
it, removing one disconnects it.
|
||||
- Hovering a profile pre-warms its backend so opening it doesn't pay a cold
|
||||
boot. On SSH gateways hovering never starts an extra profile backend on the
|
||||
remote — only opening one does.
|
||||
- The **Capabilities** page (Skills / Tools / MCP) has a matching scope: its
|
||||
**Configuring** selector lists every `(profile, device)` agent from the
|
||||
union roster, and picking one reads and writes **that machine's** skills,
|
||||
@@ -281,13 +286,14 @@ home, not a second add flow.
|
||||
|
||||
### Sessions and Bot Mode
|
||||
|
||||
Sessions intentionally show one active gateway at a time: this keeps files,
|
||||
tools, channels, cron, and session history in one understandable execution
|
||||
context. The fleet profile rail widens only the *picker* — the workspace still
|
||||
lives on exactly one `(gateway, profile)` after every click. Bot Mode serves a
|
||||
different job and may present the union roster, grouped by gateway, so a user
|
||||
can open one agent on a NAS and another on a VPS from one surface. Opening a
|
||||
bot still activates its exact `(gateway, profile)` route.
|
||||
With **All profiles** selected, the Sessions list shows the sessions of every
|
||||
registered gateway at once; group it by **Gateway & profile** to give each
|
||||
gateway its own section. Opening any of those sessions talks to its own
|
||||
gateway directly — there is no gateway to switch to first. A new chat still
|
||||
runs on exactly one `(gateway, profile)`: the one you picked, so files, tools,
|
||||
channels and cron for that chat stay in one execution context. Bot Mode
|
||||
presents the same union roster, grouped by gateway, so you can open one agent
|
||||
on a NAS and another on a VPS from one surface.
|
||||
|
||||
Direct bot mentions and delegation remain gateway-local by default. Crossing a
|
||||
backend boundary changes filesystem, credentials, tools, and trust context, so
|
||||
@@ -366,8 +372,8 @@ multi-gateway roster is the reference consumer.
|
||||
WebSocket leg failing while HTTP passes usually means a proxy, firewall, or
|
||||
gateway auth/origin guard is blocking `/api/ws`.
|
||||
- **A remote gateway is missing from the roster** — its backend is down or
|
||||
unreachable; the roster lists it under gateways with the error. SSH connections
|
||||
show *connect-on-demand* until first use — that's by design, not a failure.
|
||||
unreachable; the roster lists it under gateways with the error, and Desktop
|
||||
keeps redialing it in the background.
|
||||
- **"Update Hermes Desktop to chat with agents on other connections"** — the
|
||||
app predates the multi-connection stack; update the desktop app itself.
|
||||
- **Duplicate device names** — not possible; names are enforced unique at
|
||||
|
||||
Reference in new issue
Block a user