From 64da518db413d60ae65c3c799a0dd4f17816619e Mon Sep 17 00:00:00 2001 From: yoniebans Date: Sat, 6 Jun 2026 19:11:40 +0200 Subject: [PATCH] feat(desktop): remote update overlay sourced from backend In remote mode, checkUpdates()/applyUpdates() branch on connection.mode and drive the existing updates overlay from the connected backend instead of the local Electron git bridge: - checkUpdates -> GET /api/hermes/update/check, mapped onto DesktopUpdateStatus (behind, commits, supported=can_apply, message). The overlay renders the commit list as 'what's changed' and shows guidance (not Install) when the backend install can't self-apply (docker/nix). - applyUpdates -> POST /api/hermes/update (the proven command-center path), polling the action to completion and handling the expected mid-update connection drop as the restart phase. Local mode is unchanged. Adds checkHermesUpdate() to hermes.ts and a BackendUpdateCheckResponse type. --- apps/desktop/src/hermes.ts | 11 ++ apps/desktop/src/store/updates.test.ts | 87 ++++++++++++++- apps/desktop/src/store/updates.ts | 140 +++++++++++++++++++++++++ apps/desktop/src/types/hermes.ts | 21 ++++ 4 files changed, 258 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 631a9c0e977..da3247a36a9 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -7,6 +7,7 @@ import type { AudioSpeakResponse, AudioTranscriptionResponse, AuxiliaryModelsResponse, + BackendUpdateCheckResponse, ConfigSchemaResponse, CronJob, CronJobCreatePayload, @@ -53,6 +54,7 @@ export type { AnalyticsSkillEntry, AnalyticsSkillsSummary, AnalyticsTotals, + BackendUpdateCheckResponse, AudioSpeakResponse, AudioTranscriptionResponse, AuxiliaryModelsResponse, @@ -686,6 +688,15 @@ export function updateHermes(): Promise { }) } +/** Query the connected backend's own update state. In remote mode this is the + * authoritative source for the backend's behind-count + "what's changed", + * distinct from the Electron client clone's git state. */ +export function checkHermesUpdate(force = false): Promise { + return window.hermesDesktop.api({ + path: `/api/hermes/update/check${force ? '?force=true' : ''}` + }) +} + export function getActionStatus(name: string, lines = 200): Promise { return window.hermesDesktop.api({ path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}` diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index d013a9359c5..e8a72ff4fc9 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -23,7 +23,18 @@ vi.mock('@/store/notifications', () => ({ dismissNotification: (...args: unknown[]) => dismissSpy(...args) })) -const { maybeNotifyUpdateAvailable } = await import('./updates') +const checkHermesUpdateSpy = vi.fn() +const updateHermesSpy = vi.fn() +const getActionStatusSpy = vi.fn() + +vi.mock('@/hermes', () => ({ + checkHermesUpdate: (...args: unknown[]) => checkHermesUpdateSpy(...args), + updateHermes: (...args: unknown[]) => updateHermesSpy(...args), + getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args) +})) + +const { maybeNotifyUpdateAvailable, checkUpdates, $updateStatus } = await import('./updates') +const { setConnection } = await import('./session') const status = (over: Partial = {}): DesktopUpdateStatus => ({ supported: true, @@ -75,3 +86,77 @@ describe('maybeNotifyUpdateAvailable', () => { expect(notifySpy).not.toHaveBeenCalled() }) }) + +describe('checkUpdates in remote mode', () => { + beforeEach(() => { + storage.clear() + notifySpy.mockClear() + checkHermesUpdateSpy.mockReset() + $updateStatus.set(null) + vi.useRealTimers() + }) + + const setRemote = (on: boolean) => + setConnection({ + baseUrl: 'http://box:9119', + isFullscreen: false, + mode: on ? 'remote' : 'local', + nativeOverlayWidth: 0, + token: 't', + wsUrl: 'ws://box:9119', + logs: [], + windowButtonPosition: null + }) + + it('sources the overlay from the backend /update/check and maps commits', async () => { + setRemote(true) + checkHermesUpdateSpy.mockResolvedValue({ + install_method: 'git', + current_version: '0.16.0', + behind: 2, + update_available: true, + can_apply: true, + update_command: 'hermes update', + message: null, + commits: [{ sha: 'abc1234', summary: 'feat: x', author: 'a', at: 1 }] + }) + + const result = await checkUpdates() + + expect(checkHermesUpdateSpy).toHaveBeenCalled() + expect(result?.behind).toBe(2) + expect(result?.commits?.[0]?.sha).toBe('abc1234') + expect(result?.supported).toBe(true) + expect($updateStatus.get()?.commits?.[0]?.summary).toBe('feat: x') + }) + + it('honours can_apply=false (docker/nix): not supported, carries message', async () => { + setRemote(true) + checkHermesUpdateSpy.mockResolvedValue({ + install_method: 'docker', + current_version: '0.16.0', + behind: null, + update_available: false, + can_apply: false, + update_command: 'docker pull ...', + message: 'Docker images are immutable.' + }) + + const result = await checkUpdates() + + expect(result?.supported).toBe(false) + expect(result?.message).toBe('Docker images are immutable.') + }) + + it('does NOT call the backend check in local mode', async () => { + setRemote(false) + // No hermesDesktop bridge → local path early-returns without hitting the + // backend. Stub a bare window so the local branch can read the (absent) + // bridge without throwing in the node test env. + vi.stubGlobal('window', {}) + await checkUpdates() + expect(checkHermesUpdateSpy).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) +}) + diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index ad568093f35..add181491c8 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -13,9 +13,12 @@ import type { DesktopUpdateStatus, DesktopVersionInfo } from '@/global' +import { checkHermesUpdate, getActionStatus, updateHermes } from '@/hermes' import { translateNow } from '@/i18n' import { persistString, storedString } from '@/lib/storage' import { dismissNotification, notify } from '@/store/notifications' +import { $connection } from '@/store/session' +import type { BackendUpdateCheckResponse } from '@/types/hermes' export interface UpdateApplyState { applying: boolean @@ -174,7 +177,69 @@ export async function refreshDesktopVersion(): Promise 0 ? behind : 0, + // targetSha gates the "update available" toast in maybeNotifyUpdateAvailable; + // synthesize a stable marker when the backend reports it's behind. + targetSha: res.update_available ? `backend:${res.current_version}` : undefined, + commits: res.commits, + fetchedAt: Date.now() + } +} + +async function checkBackendUpdates(): Promise { + if ($updateChecking.get()) { + return $updateStatus.get() + } + + $updateChecking.set(true) + + try { + const res = await checkHermesUpdate(true) + const status = mapBackendCheck(res) + $updateStatus.set(status) + maybeNotifyUpdateAvailable(status) + + return status + } catch (error) { + const previous = $updateStatus.get() + const fallback: DesktopUpdateStatus = { + supported: previous?.supported ?? true, + branch: previous?.branch, + error: 'check-failed', + message: error instanceof Error ? error.message : String(error), + fetchedAt: Date.now() + } + + $updateStatus.set(fallback) + + return fallback + } finally { + $updateChecking.set(false) + } +} + export async function checkUpdates(): Promise { + // Remote thin-client mode: the version pill points at the BACKEND, not the + // local Electron clone. Source the overlay from the backend's own + // /api/hermes/update/check so behind-count + "what's changed" describe the + // machine the user is actually connected to. + if (isRemoteMode()) { + return checkBackendUpdates() + } + const bridge = window.hermesDesktop?.updates if (!bridge || $updateChecking.get()) { @@ -213,6 +278,14 @@ export async function checkUpdates(): Promise { } export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promise { + // Remote mode: apply the update on the BACKEND via its HTTP API (the same + // path the command-center "Update Hermes" button uses), then poll the action + // to completion. The Electron git bridge would update the local client clone, + // which is the wrong target when the version pill points at a remote backend. + if (isRemoteMode()) { + return applyBackendUpdate() + } + const bridge = window.hermesDesktop?.updates if (!bridge) { @@ -247,6 +320,73 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis } } +/** Apply the update on the connected backend: POST /api/hermes/update, then + * poll the spawned action to completion. Drives $updateApply so the overlay + * shows progress + a terminal state, mirroring the local apply flow. */ +async function applyBackendUpdate(): Promise { + dismissNotification(UPDATE_TOAST_ID) + $updateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Updating backend…' }) + + try { + const started = await updateHermes() + + // updateHermes returns ok:false for non-applyable installs (e.g. docker) + // with guidance in the response; surface it as a manual state. + if (!started.ok) { + const message = (started as { message?: string }).message || 'Update not available for this backend.' + const command = (started as { update_command?: string }).update_command || 'hermes update' + $updateApply.set({ ...IDLE, applying: false, stage: 'manual', message, command }) + + return { ok: false, error: 'manual', manual: true, message, command } + } + + $updateApply.set({ ...IDLE, applying: true, stage: 'pull', message: 'Backend updating…' }) + + // Poll the action until it stops running (cap the wait — the dashboard + // restarts mid-update, which drops this connection; that's expected). + let last: Awaited> | null = null + for (let attempt = 0; attempt < 30; attempt += 1) { + await new Promise(resolve => window.setTimeout(resolve, 1500)) + try { + last = await getActionStatus(started.name, 200) + } catch { + // Connection dropped — most likely the backend restarted to load the + // new code. Treat as the (expected) restart phase, not a failure. + $updateApply.set({ + ...$updateApply.get(), + applying: false, + stage: 'restart', + message: 'Backend restarting to load the update…' + }) + + return { ok: true, message: 'Backend update applied; backend is restarting.' } + } + + if (last && !last.running) { + break + } + } + + const ok = !!last && (last.exit_code ?? 1) === 0 + $updateApply.set({ + ...$updateApply.get(), + applying: false, + stage: ok ? 'restart' : 'error', + error: ok ? null : 'apply-failed', + message: ok ? 'Backend updated. Restart it to load the new code.' : 'Backend update failed.' + }) + + return ok + ? { ok: true, message: 'Backend update applied.' } + : { ok: false, error: 'apply-failed', message: 'Backend update failed.' } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + $updateApply.set({ ...$updateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message }) + + return { ok: false, error: 'apply-failed', message } + } +} + function ingestProgress(payload: DesktopUpdateProgress): void { const current = $updateApply.get() const log = [...current.log, { stage: payload.stage, message: payload.message, at: payload.at }].slice(-50) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 8d646f4c7fb..5d362e51ef6 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -596,6 +596,27 @@ export interface ActionStatusResponse { running: boolean } +export interface BackendUpdateCommit { + sha: string + summary: string + author: string + at: number +} + +/** Shape of `GET /api/hermes/update/check` — the backend's own update state. + * Used by the desktop's remote update overlay so the backend version (not the + * Electron client clone) drives "what's changed + Install" in remote mode. */ +export interface BackendUpdateCheckResponse { + install_method: string + current_version: string + behind: number | null + update_available: boolean + can_apply: boolean + update_command: string | null + message: string | null + commits?: BackendUpdateCommit[] +} + export interface AuxiliaryTaskAssignment { base_url: string model: string