From 4ef92d2e5dc95fa1e6f8818f454d896bb9826dd3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:40:04 -0700 Subject: [PATCH] fix(desktop): survive older backends missing the batched sidebar endpoint (#67986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stacked failures turned desktop/runtime version skew into a full 'Hermes couldn't start' boot brick: 1. listSidebarSessions() had no fallback when the backend predates the batched /api/profiles/sessions/sidebar route (added Jul 18) — the backend catch-all 404 ('No such API endpoint') rejected the refresh. 2. boot() awaited refreshSessions() unguarded inside Promise.all — unlike the reconnect and softSwitch call sites — so a session-LIST failure rejected the whole boot and raised failDesktopBoot() even though the gateway WS was already open and the app was usable. Fixes: - listSidebarSessions() now detects endpoint-missing errors (backend catch-all, IPC-wrapped 404, Electron HTML-guard), falls back to the three proven per-slice /api/profiles/sessions calls with identical scoping (recents on the caller's profile, cron + messaging cross-profile), and remembers the verdict so later refreshes skip the dead probe. Transient failures (timeout, 5xx, ECONNREFUSED) still throw — no silent fast-path degradation from one blip. - wipeSessionListsForGatewaySwitch() resets the capability flag so a soft gateway switch re-probes the next backend instead of leaking the old one's verdict (hard re-homes reload the window and reset anyway). - boot() treats refreshSessions() as non-fatal, matching its sibling call sites: worst case is an empty sidebar that the next reconnect/turn refresh repopulates, never a bricked boot. Tests: endpoint-missing fallback slices + scoping, sticky verdict (no re-probe), re-probe after gateway switch, transient errors NOT triggering fallback, and a real-hook harness test proving a rejecting refreshSessions still completes boot with no overlay. --- .../gateway/hooks/use-gateway-boot.test.tsx | 25 ++- .../src/app/gateway/hooks/use-gateway-boot.ts | 10 +- apps/desktop/src/hermes.test.ts | 147 +++++++++++++++++- apps/desktop/src/hermes.ts | 86 +++++++++- apps/desktop/src/store/gateway-switch.ts | 4 + 5 files changed, 264 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index 2672e95a676e..4e48eb8d0bf7 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -105,13 +105,13 @@ function fakeDesktop() { } } -function Harness() { +function Harness({ refreshSessions }: { refreshSessions?: () => Promise } = {}) { useGatewayBoot({ handleGatewayEvent: () => undefined, onConnectionReady: () => undefined, onGatewayReady: () => undefined, refreshHermesConfig: async () => undefined, - refreshSessions: async () => undefined + refreshSessions: refreshSessions ?? (async () => undefined) }) return null @@ -267,4 +267,25 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => expect($gatewayState.get()).toBe('open') expect($desktopBoot.get().error).toBeNull() }) + + it('FIX: a failed session-list fetch during boot is non-fatal — the app still boots', async () => { + // The version-skew report: gateway WS connects fine, but refreshSessions() + // rejects (e.g. older backend 404s an endpoint the fallback didn't cover, + // or a transient read error). That must NOT reject boot() into + // failDesktopBoot's "Hermes couldn't start" overlay — the socket is open + // and the app is fully usable with an empty sidebar. + const refreshSessions = vi.fn(async () => { + throw new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}') + }) + + render() + await flushAsync() + + expect(refreshSessions).toHaveBeenCalled() + expect($gatewayState.get()).toBe('open') + // Boot completed: no error, overlay dismissed. + expect($desktopBoot.get().error).toBeNull() + expect($desktopBoot.get().visible).toBe(false) + expect($desktopBoot.get().phase).toBe('renderer.ready') + }) }) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 786cf4011ab7..d60841813272 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -480,7 +480,15 @@ export function useGatewayBoot({ await Promise.all([ seedDefaultCwd(), callbacksRef.current.refreshHermesConfig(), - callbacksRef.current.refreshSessions() + // Session-list population is never boot-fatal. The gateway WS is + // already open by this point — a failed sidebar fetch (transient + // blip, or an endpoint the fallback couldn't cover) must leave the + // app usable with an empty sidebar (the reconnect/turn refreshes + // retry it), not brick boot behind the "Hermes couldn't start" + // overlay. Matches the reconnect + softSwitch call sites. + callbacksRef.current.refreshSessions().catch(() => { + setSessionsLoading(false) + }) ]) if (cancelled) { diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 3de900c18b2f..0a6553d24284 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -11,7 +11,8 @@ import { getStatus, listAllProfileSessions, listSessions, - listSidebarSessions + listSidebarSessions, + resetSidebarBatchCapability } from './hermes' import { refreshActiveProfile } from './store/profile' @@ -26,6 +27,7 @@ describe('Hermes REST session helpers', () => { let api: ReturnType beforeEach(() => { + resetSidebarBatchCapability() api = vi.fn().mockResolvedValue(emptySessionsResponse) Object.defineProperty(window, 'hermesDesktop', { configurable: true, @@ -99,6 +101,149 @@ describe('Hermes REST session helpers', () => { expect(result.messaging.sessions).toEqual([]) }) + it('falls back to the per-slice endpoint when the batched route 404s on an older backend', async () => { + const row = (id: string) => ({ id, title: id, profile: 'default' }) + + api.mockImplementation(({ path }: { path: string }) => { + if (path.startsWith('/api/profiles/sessions/sidebar')) { + // The exact skew failure: Electron surfaces the backend catch-all. + return Promise.reject( + new Error('Error invoking remote method \'hermes:api\': Error: 404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}') + ) + } + + if (path.includes('source=cron')) { + return Promise.resolve({ ...emptySessionsResponse, sessions: [row('cron-1')], total: 1 }) + } + + if (path.includes('exclude_sources=cron%2Cdesktop')) { + return Promise.resolve({ ...emptySessionsResponse, sessions: [row('msg-1')], total: 1 }) + } + + return Promise.resolve({ + ...emptySessionsResponse, + sessions: [row('recent-1')], + total: 7, + profile_totals: { default: 7 } + }) + }) + + const result = await listSidebarSessions({ + recentsProfile: 'work', + recentsLimit: 30, + recentsExclude: ['cron', 'tool'], + cronLimit: 50, + messagingLimit: 100, + messagingExclude: ['cron', 'desktop'] + }) + + // Slices reassembled from the legacy per-slice route with the same + // scoping: recents on the caller's profile, cron + messaging cross-profile. + expect(result.recents.sessions.map(s => s.id)).toEqual(['recent-1']) + expect(result.recents.total).toBe(7) + expect(result.recents.profile_totals).toEqual({ default: 7 }) + expect(result.cron.sessions.map(s => s.id)).toEqual(['cron-1']) + expect(result.messaging.sessions.map(s => s.id)).toEqual(['msg-1']) + + const paths = api.mock.calls.map(call => (call[0] as { path: string }).path) + expect(paths.filter(p => p.startsWith('/api/profiles/sessions/sidebar'))).toHaveLength(1) + expect(paths.filter(p => p.startsWith('/api/profiles/sessions?'))).toHaveLength(3) + expect(paths).toContainEqual(expect.stringContaining('profile=work')) + expect(paths).toContainEqual(expect.stringContaining('source=cron')) + expect(paths).toContainEqual(expect.stringContaining('exclude_sources=cron%2Ctool')) + }) + + it('remembers endpoint-missing and skips re-probing the batched route on later refreshes', async () => { + api.mockImplementation(({ path }: { path: string }) => + path.startsWith('/api/profiles/sessions/sidebar') + ? Promise.reject(new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}')) + : Promise.resolve(emptySessionsResponse) + ) + + const req = { + recentsProfile: 'all' as const, + recentsLimit: 20, + recentsExclude: [], + cronLimit: 50, + messagingLimit: 100, + messagingExclude: [] + } + + await listSidebarSessions(req) + await listSidebarSessions(req) + + const batchedProbes = api.mock.calls.filter(call => + (call[0] as { path: string }).path.startsWith('/api/profiles/sessions/sidebar') + ) + + // First refresh probes once and learns; the second goes straight to the + // per-slice route (3 calls each refresh, no repeated dead probe). + expect(batchedProbes).toHaveLength(1) + expect(api.mock.calls.length).toBe(1 + 3 + 3) + }) + + it('re-probes the batched route after a gateway switch resets the capability flag', async () => { + api.mockImplementation(({ path }: { path: string }) => + path.startsWith('/api/profiles/sessions/sidebar') + ? Promise.reject(new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}')) + : Promise.resolve(emptySessionsResponse) + ) + + const req = { + recentsProfile: 'all' as const, + recentsLimit: 20, + recentsExclude: [], + cronLimit: 50, + messagingLimit: 100, + messagingExclude: [] + } + + await listSidebarSessions(req) + // Soft gateway switch: the next backend may support the batched route. + resetSidebarBatchCapability() + api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } }) + + const result = await listSidebarSessions(req) + + const batchedProbes = api.mock.calls.filter(call => + (call[0] as { path: string }).path.startsWith('/api/profiles/sessions/sidebar') + ) + + expect(batchedProbes).toHaveLength(2) + expect(result.recents.sessions).toEqual([]) + }) + + it('does NOT fall back on transient failures — only endpoint-missing shapes trigger legacy mode', async () => { + api.mockRejectedValue(new Error('Request timed out after 60000ms')) + + await expect( + listSidebarSessions({ + recentsProfile: 'all', + recentsLimit: 20, + recentsExclude: [], + cronLimit: 50, + messagingLimit: 100, + messagingExclude: [] + }) + ).rejects.toThrow('timed out') + + // One call: the batched probe. No legacy fan-out, no sticky degradation. + expect(api).toHaveBeenCalledTimes(1) + + // And the next refresh still uses the batched route. + api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } }) + await listSidebarSessions({ + recentsProfile: 'all', + recentsLimit: 20, + recentsExclude: [], + cronLimit: 50, + messagingLimit: 100, + messagingExclude: [] + }) + + expect((api.mock.calls[1][0] as { path: string }).path).toMatch(/^\/api\/profiles\/sessions\/sidebar\?/) + }) + it('uses a longer timeout for profile listing during desktop startup', async () => { api.mockResolvedValue({ profiles: [] }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 24d28ca6af7c..d84f85815d85 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -394,7 +394,72 @@ export interface SidebarSessionsRequest { messagingExclude: string[] } +// The batched /sidebar endpoint shipped later than the per-slice route, so a +// newer desktop can meet an older backend that 404s it ("No such API +// endpoint"). Endpoint-missing is a capability signal, not a transient +// failure: remember it (per renderer lifetime — a runtime home change reloads +// the window and re-probes) and serve every subsequent refresh straight from +// the three proven per-slice calls instead of re-probing a known-dead route +// once per turn/broadcast. +let sidebarBatchEndpointMissing = false + +// Capability flags are per-backend facts. A hard re-home reloads the window +// (module state resets naturally), but a soft gateway switch re-dials in +// place — the next backend may well have the batched route, so the switch +// paths call this to re-probe rather than leak the old backend's capability. +export function resetSidebarBatchCapability() { + sidebarBatchEndpointMissing = false +} + +// True only for "the route does not exist on this backend" shapes: the +// backend catch-all ('404: {"detail":"No such API endpoint: ...}'), FastAPI's +// bare 404 on headless serve (surfaces as '404: ...' directly or as +// "Error invoking remote method 'hermes:api': Error: 404: ..." through the +// IPC bridge), and the Electron JSON-guard ("endpoint is likely missing"). +// This GET has no path params, so a 404 status can only mean route-missing — +// but transient failures (timeouts, 5xx, connection refused) must NOT match, +// or one blip would silently degrade the fast path for the whole session. +function isEndpointMissingError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err) + + return ( + /no such api endpoint/i.test(message) || + /endpoint is likely missing/i.test(message) || + /(?:^\s*|error:\s*)404\b/i.test(message) + ) +} + +// Compatibility fallback: reassemble the three sidebar slices from the +// per-slice endpoint, mirroring the batched route's semantics (min_messages=1, +// archived excluded, recency order; recents scoped to the caller's profile, +// cron + messaging cross-profile). Rides the same Electron remote-splice +// interception as the pre-batching desktop, so remote profiles stay correct. +async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise { + const [recents, cron, messaging] = await Promise.all([ + listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, { + excludeSources: req.recentsExclude + }), + listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', 'all', { source: 'cron' }), + listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', 'all', { + excludeSources: req.messagingExclude + }) + ]) + + const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])] + + return { + recents: { profile_totals: recents.profile_totals, sessions: recents.sessions, total: recents.total }, + cron: { sessions: cron.sessions }, + messaging: { sessions: messaging.sessions }, + ...(errors.length ? { errors } : {}) + } +} + export async function listSidebarSessions(req: SidebarSessionsRequest): Promise { + if (sidebarBatchEndpointMissing) { + return listSidebarSessionsLegacy(req) + } + const params = new URLSearchParams({ recents_profile: req.recentsProfile, recents_limit: String(Math.max(1, req.recentsLimit)), @@ -410,10 +475,23 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise< params.set('messaging_exclude', req.messagingExclude.join(',')) } - const result = await window.hermesDesktop.api({ - path: `/api/profiles/sessions/sidebar?${params.toString()}`, - timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS - }) + let result: SidebarSessionsResponse + + try { + result = await window.hermesDesktop.api({ + path: `/api/profiles/sessions/sidebar?${params.toString()}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + } catch (err) { + if (!isEndpointMissingError(err)) { + throw err + } + + // Older backend without the batched route (desktop/runtime version skew). + sidebarBatchEndpointMissing = true + + return listSidebarSessionsLegacy(req) + } return { recents: { ...result.recents, sessions: result.recents?.sessions ?? [] }, diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts index 2b6fb466e8e7..078c3dd064d1 100644 --- a/apps/desktop/src/store/gateway-switch.ts +++ b/apps/desktop/src/store/gateway-switch.ts @@ -1,5 +1,6 @@ import { atom } from 'nanostores' +import { resetSidebarBatchCapability } from '@/hermes' import { invalidateProfileScopedQueries } from '@/lib/query-client' import { resetSessionsLimit } from '@/store/layout' import { @@ -37,6 +38,9 @@ export const $gatewaySwitching = atom(false) * alone so the user stays where they were (e.g. mid-Gateway settings). */ export function wipeSessionListsForGatewaySwitch(): void { + // The next backend is a different runtime — don't carry the old one's + // "batched sidebar endpoint missing" capability verdict across the switch. + resetSidebarBatchCapability() setSessions([]) setSessionsTotal(0) setSessionProfileTotals({})