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.
This commit is contained in:
yoniebans 2026-06-06 19:11:40 +02:00 committed by Teknium
parent ed1e2533b7
commit 64da518db4
4 changed files with 258 additions and 1 deletions

View file

@ -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<ActionResponse> {
})
}
/** 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<BackendUpdateCheckResponse> {
return window.hermesDesktop.api<BackendUpdateCheckResponse>({
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
})
}
export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> {
return window.hermesDesktop.api<ActionStatusResponse>({
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`

View file

@ -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> = {}): 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()
})
})

View file

@ -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<DesktopVersionInfo | null
}
}
function isRemoteMode(): boolean {
return $connection.get()?.mode === 'remote'
}
/** Map the backend's /api/hermes/update/check shape onto the overlay's
* DesktopUpdateStatus. `can_apply` / `message` are preserved so the overlay
* can show guidance (e.g. docker/nix) instead of an Install button. */
function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
const behind = res.behind ?? 0
return {
supported: res.can_apply,
message: res.message ?? undefined,
behind: behind > 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<DesktopUpdateStatus | null> {
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<DesktopUpdateStatus | null> {
// 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<DesktopUpdateStatus | null> {
}
export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promise<DesktopUpdateApplyResult> {
// 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<DesktopUpdateApplyResult> {
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<ReturnType<typeof getActionStatus>> | 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)

View file

@ -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