diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 14c56b6ecd1a..0e5c8e2c8b5f 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -41,8 +41,14 @@ import { setYoloActive } from '@/store/session' import { $subagentsBySession, activeSubagentCount } from '@/store/subagents' -import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates' -import { resolveVersionBar } from '@/lib/version-bar' +import { + $backendUpdateApply, + $backendUpdateStatus, + $desktopVersion, + $updateApply, + $updateStatus, + openUpdateOverlayFor +} from '@/store/updates' import type { StatusResponse } from '@/types/hermes' import { CRON_ROUTE } from '../../routes' @@ -99,6 +105,8 @@ export function useStatusbarItems({ const subagentsBySession = useStore($subagentsBySession) const updateStatus = useStore($updateStatus) const updateApply = useStore($updateApply) + const backendUpdateStatus = useStore($backendUpdateStatus) + const backendUpdateApply = useStore($backendUpdateApply) const desktopVersion = useStore($desktopVersion) const connection = useStore($connection) @@ -197,36 +205,25 @@ export function useStatusbarItems({ ? 'text-amber-600 hover:text-amber-600' : 'text-destructive hover:text-destructive' - const versionItem = useMemo(() => { + const clientVersionItem = useMemo(() => { const appVersion = desktopVersion?.appVersion const sha = updateStatus?.currentSha?.slice(0, 7) ?? null const behind = updateStatus?.behind ?? 0 const applying = updateApply.applying || updateApply.stage === 'restart' + const remote = connection?.mode === 'remote' - // In remote thin-client mode the client and backend are separate installs; - // show both versions and flag skew. Local mode is unchanged. - const versionBar = resolveVersionBar({ - appVersion, - sha, - backendVersion: statusSnapshot?.version, - mode: connection?.mode, - copy: { clientLabel: copy.clientLabel, backendLabel: copy.backendLabel, unknown: copy.unknown } - }) - - const base = versionBar.label + const version = appVersion ? `v${appVersion}` : (sha ?? copy.unknown) + const base = remote ? copy.clientLabel(appVersion ?? sha ?? copy.unknown) : version const behindHint = !applying && behind > 0 ? ` (+${behind})` : '' const label = applying - ? updateApply.stage === 'restart' - ? `${base} · ${copy.restart}` - : `${base} · ${copy.update}` + ? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}` : `${base}${behindHint}` const tooltip = [ applying ? updateApply.message || copy.updateInProgress : null, !applying && behind > 0 && copy.commitsBehind(behind, updateStatus?.branch ?? '...'), appVersion && copy.desktopVersion(appVersion), - versionBar.showsBackend && versionBar.backendVersion && copy.backendVersion(versionBar.backendVersion), sha && copy.commit(sha), updateStatus?.branch && copy.branch(updateStatus.branch) ] @@ -234,21 +231,19 @@ export function useStatusbarItems({ .join(' · ') return { - className: - versionBar.skew || (!applying && behind > 0) ? 'text-primary hover:text-primary' : undefined, - detail: appVersion && sha && !applying && !versionBar.showsBackend ? sha : undefined, + className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined, + detail: appVersion && sha && !applying && !remote ? sha : undefined, hidden: !appVersion && !sha, icon: applying ? : , - id: 'version', + id: 'version-client', label, - onSelect: () => setUpdateOverlayOpen(true), + onSelect: () => openUpdateOverlayFor('client'), title: tooltip || undefined, variant: 'action' } }, [ desktopVersion?.appVersion, connection?.mode, - statusSnapshot?.version, copy, updateApply.applying, updateApply.message, @@ -258,6 +253,50 @@ export function useStatusbarItems({ updateStatus?.currentSha ]) + const backendVersionItem = useMemo(() => { + if (connection?.mode !== 'remote') { + return null + } + + const backendVersion = statusSnapshot?.version + const behind = backendUpdateStatus?.behind ?? 0 + const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart' + + const base = copy.backendLabel(backendVersion ?? copy.unknown) + const behindHint = !applying && behind > 0 ? ` (+${behind})` : '' + + const label = applying + ? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}` + : `${base}${behindHint}` + + const tooltip = [ + applying ? backendUpdateApply.message || copy.updateInProgress : null, + !applying && behind > 0 && copy.commitsBehind(behind, 'main'), + backendVersion && copy.backendVersion(backendVersion) + ] + .filter(Boolean) + .join(' · ') + + return { + className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined, + hidden: !backendVersion, + icon: applying ? : , + id: 'version-backend', + label, + onSelect: () => openUpdateOverlayFor('backend'), + title: tooltip || undefined, + variant: 'action' + } + }, [ + connection?.mode, + statusSnapshot?.version, + backendUpdateStatus?.behind, + backendUpdateApply.applying, + backendUpdateApply.message, + backendUpdateApply.stage, + copy + ]) + const coreLeftStatusbarItems = useMemo( () => [ { @@ -403,7 +442,8 @@ export function useStatusbarItems({ variant: 'action' as const }) }, - versionItem + ...(backendVersionItem ? [backendVersionItem] : []), + clientVersionItem ], [ busy, @@ -419,7 +459,8 @@ export function useStatusbarItems({ showYoloToggle, toggleYolo, turnStartedAt, - versionItem, + clientVersionItem, + backendVersionItem, yoloActive ] ) diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 83c011dfabda..7c354eb17152 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -13,40 +13,51 @@ import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog' import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons' import { cn } from '@/lib/utils' import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy' -import { $connection } from '@/store/session' import { + $backendUpdateApply, + $backendUpdateChecking, + $backendUpdateStatus, $updateApply, $updateChecking, $updateOverlayOpen, + $updateOverlayTarget, $updateStatus, + applyBackendUpdate, applyUpdates, + checkBackendUpdates, checkUpdates, resetUpdateApplyState, setUpdateOverlayOpen, type UpdateApplyState } from '@/store/updates' -/** What the overlay is acting on. In remote thin-client mode the overlay - * targets the connected BACKEND; otherwise the local desktop client. - * (Type re-exported from the copy helper.) */ - function totalItems(groups: readonly CommitGroup[]) { return groups.reduce((sum, g) => sum + g.items.length, 0) } export function UpdatesOverlay() { const open = useStore($updateOverlayOpen) - const status = useStore($updateStatus) - const checking = useStore($updateChecking) - const apply = useStore($updateApply) - const connection = useStore($connection) - const target: UpdateTarget = connection?.mode === 'remote' ? 'backend' : 'client' + const target = useStore($updateOverlayTarget) + + const clientStatus = useStore($updateStatus) + const clientChecking = useStore($updateChecking) + const clientApply = useStore($updateApply) + const backendStatus = useStore($backendUpdateStatus) + const backendChecking = useStore($backendUpdateChecking) + const backendApply = useStore($backendUpdateApply) + + const isBackend = target === 'backend' + const status = isBackend ? backendStatus : clientStatus + const checking = isBackend ? backendChecking : clientChecking + const apply = isBackend ? backendApply : clientApply + const check = isBackend ? checkBackendUpdates : checkUpdates + const install = isBackend ? applyBackendUpdate : applyUpdates useEffect(() => { if (open && !status && !checking) { - void checkUpdates() + void check() } - }, [checking, open, status]) + }, [check, checking, open, status]) const behind = status?.behind ?? 0 @@ -72,7 +83,7 @@ export function UpdatesOverlay() { } const handleInstall = () => { - void applyUpdates() + void install() } return ( @@ -98,7 +109,7 @@ export function UpdatesOverlay() { commits={status?.commits ?? []} onInstall={handleInstall} onLater={() => handleClose(false)} - onRetryCheck={() => void checkUpdates()} + onRetryCheck={() => void check()} status={status} target={target} /> diff --git a/apps/desktop/src/lib/version-bar.test.ts b/apps/desktop/src/lib/version-bar.test.ts deleted file mode 100644 index b8f98d9a9843..000000000000 --- a/apps/desktop/src/lib/version-bar.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { resolveVersionBar } from './version-bar' - -const copy = { - clientLabel: (v: string) => `client v${v}`, - backendLabel: (v: string) => `backend v${v}`, - unknown: 'unknown' -} - -describe('resolveVersionBar', () => { - it('local mode: shows only the client version (unchanged behaviour)', () => { - const result = resolveVersionBar({ - appVersion: '0.16.0', - backendVersion: '0.16.0', - mode: 'local', - copy - }) - - expect(result.label).toBe('v0.16.0') - expect(result.showsBackend).toBe(false) - }) - - it('remote mode: shows BOTH client and backend versions', () => { - const result = resolveVersionBar({ - appVersion: '0.15.1', - backendVersion: '0.16.0', - mode: 'remote', - copy - }) - - expect(result.label).toContain('client v0.15.1') - expect(result.label).toContain('backend v0.16.0') - expect(result.showsBackend).toBe(true) - expect(result.backendVersion).toBe('0.16.0') - }) - - it('remote mode without a backend version yet: falls back to client-only, no crash', () => { - const result = resolveVersionBar({ - appVersion: '0.15.1', - backendVersion: undefined, - mode: 'remote', - copy - }) - - expect(result.label).toBe('v0.15.1') - expect(result.showsBackend).toBe(false) - }) - - it('remote mode where both versions match: still shows both (no implicit hiding)', () => { - const result = resolveVersionBar({ - appVersion: '0.16.0', - backendVersion: '0.16.0', - mode: 'remote', - copy - }) - - expect(result.label).toContain('client v0.16.0') - expect(result.label).toContain('backend v0.16.0') - expect(result.showsBackend).toBe(true) - }) - - it('no client version at all: uses sha fallback for the base, local mode', () => { - const result = resolveVersionBar({ - appVersion: undefined, - sha: 'abc1234', - backendVersion: undefined, - mode: 'local', - copy - }) - - expect(result.label).toBe('abc1234') - expect(result.showsBackend).toBe(false) - }) - - it('exposes a skew flag when remote versions differ', () => { - const skewed = resolveVersionBar({ - appVersion: '0.15.1', - backendVersion: '0.16.0', - mode: 'remote', - copy - }) - const aligned = resolveVersionBar({ - appVersion: '0.16.0', - backendVersion: '0.16.0', - mode: 'remote', - copy - }) - - expect(skewed.skew).toBe(true) - expect(aligned.skew).toBe(false) - }) -}) diff --git a/apps/desktop/src/lib/version-bar.ts b/apps/desktop/src/lib/version-bar.ts deleted file mode 100644 index 27b80ebca19e..000000000000 --- a/apps/desktop/src/lib/version-bar.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Pure logic for the status-bar version item. - * - * In local mode the desktop app and its backend are the same install, so a - * single version is correct and we keep today's behaviour byte-for-byte. In - * remote (thin-client) mode the Electron client and the backend it connects to - * are separate installs that drift independently — so we surface BOTH the - * client version and the connected backend version, and flag skew. - * - * Kept as a pure helper (no React, no stores) so it's unit-testable in - * isolation; the status-bar hook composes the label/click from this. - */ - -export interface VersionBarCopy { - clientLabel: (version: string) => string - backendLabel: (version: string) => string - unknown: string -} - -export interface ResolveVersionBarInput { - /** Desktop client version (from the Electron getVersion IPC). */ - appVersion?: string - /** Short git sha fallback when no semantic version is available. */ - sha?: string | null - /** Backend version reported by the connected gateway (StatusResponse.version). */ - backendVersion?: string - /** Connection topology. Only 'remote' shows two versions. */ - mode?: 'local' | 'remote' - copy: VersionBarCopy -} - -export interface VersionBarResult { - /** Composed status-bar label. */ - label: string - /** True when the backend version is shown alongside the client version. */ - showsBackend: boolean - /** The backend version actually shown (when showsBackend). */ - backendVersion?: string - /** True when remote client and backend versions differ. */ - skew: boolean -} - -/** - * Compute the version-bar label and skew state. - * - * Local mode (or remote with no backend version yet) → single client version, - * identical to the pre-existing behaviour. Remote mode with a backend version → - * "client vX · backend vY". - */ -export function resolveVersionBar(input: ResolveVersionBarInput): VersionBarResult { - const { appVersion, sha, backendVersion, mode, copy } = input - - const clientBase = appVersion ? `v${appVersion}` : (sha ?? copy.unknown) - - const isRemote = mode === 'remote' - const showsBackend = isRemote && Boolean(backendVersion) && Boolean(appVersion) - - if (!showsBackend) { - return { - label: clientBase, - showsBackend: false, - skew: false - } - } - - const skew = backendVersion !== appVersion - - return { - label: `${copy.clientLabel(appVersion as string)} · ${copy.backendLabel(backendVersion as string)}`, - showsBackend: true, - backendVersion, - skew - } -} diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index e8a72ff4fc90..30d1965593b9 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -33,7 +33,7 @@ vi.mock('@/hermes', () => ({ getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args) })) -const { maybeNotifyUpdateAvailable, checkUpdates, $updateStatus } = await import('./updates') +const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus } = await import('./updates') const { setConnection } = await import('./session') const status = (over: Partial = {}): DesktopUpdateStatus => ({ @@ -87,12 +87,12 @@ describe('maybeNotifyUpdateAvailable', () => { }) }) -describe('checkUpdates in remote mode', () => { +describe('checkBackendUpdates', () => { beforeEach(() => { storage.clear() notifySpy.mockClear() checkHermesUpdateSpy.mockReset() - $updateStatus.set(null) + $backendUpdateStatus.set(null) vi.useRealTimers() }) @@ -108,7 +108,7 @@ describe('checkUpdates in remote mode', () => { windowButtonPosition: null }) - it('sources the overlay from the backend /update/check and maps commits', async () => { + it('maps the backend /update/check onto the backend status, including commits', async () => { setRemote(true) checkHermesUpdateSpy.mockResolvedValue({ install_method: 'git', @@ -121,13 +121,13 @@ describe('checkUpdates in remote mode', () => { commits: [{ sha: 'abc1234', summary: 'feat: x', author: 'a', at: 1 }] }) - const result = await checkUpdates() + const result = await checkBackendUpdates() 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') + expect($backendUpdateStatus.get()?.commits?.[0]?.summary).toBe('feat: x') }) it('honours can_apply=false (docker/nix): not supported, carries message', async () => { @@ -142,21 +142,16 @@ describe('checkUpdates in remote mode', () => { message: 'Docker images are immutable.' }) - const result = await checkUpdates() + const result = await checkBackendUpdates() expect(result?.supported).toBe(false) expect(result?.message).toBe('Docker images are immutable.') }) - it('does NOT call the backend check in local mode', async () => { + it('is a no-op in local mode (backend check only runs when remote)', 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() + await checkBackendUpdates() expect(checkHermesUpdateSpy).not.toHaveBeenCalled() - vi.unstubAllGlobals() }) }) diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index add181491c8b..0a676d13819f 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -48,8 +48,24 @@ export const $updateChecking = atom(false) export const $updateOverlayOpen = atom(false) export const $updateStatus = atom(null) +// Client and backend are independently updatable; each keeps its own state. +export const $backendUpdateStatus = atom(null) +export const $backendUpdateApply = atom(IDLE) +export const $backendUpdateChecking = atom(false) + +export type UpdateTarget = 'client' | 'backend' +export const $updateOverlayTarget = atom('client') + export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(open) -export const resetUpdateApplyState = () => $updateApply.set(IDLE) +export const openUpdateOverlayFor = (target: UpdateTarget) => { + $updateOverlayTarget.set(target) + $updateOverlayOpen.set(true) + void (target === 'backend' ? checkBackendUpdates() : checkUpdates()) +} +export const resetUpdateApplyState = () => { + $updateApply.set(IDLE) + $backendUpdateApply.set(IDLE) +} const UPDATE_TOAST_ID = 'desktop-update-available' // Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits @@ -89,7 +105,7 @@ export function reportBackendContract(contract: number | undefined): void { } notify({ - action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() }, + action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() }, durationMs: 0, id: SKEW_TOAST_ID, kind: 'warning', @@ -140,13 +156,8 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) { }) } -/** - * Opens the updates dialog and kicks off a fresh check so the user always - * sees current state, even if a stale status is cached from earlier. - */ export function openUpdatesWindow(): void { - $updateOverlayOpen.set(true) - void checkUpdates() + openUpdateOverlayFor(isRemoteMode() ? 'backend' : 'client') } /** Re-read the running app's version from the Electron main process and @@ -181,9 +192,6 @@ 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 @@ -191,55 +199,42 @@ function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus { 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 { - if ($updateChecking.get()) { - return $updateStatus.get() +export async function checkBackendUpdates(): Promise { + if (!isRemoteMode() || $backendUpdateChecking.get()) { + return $backendUpdateStatus.get() } - $updateChecking.set(true) + $backendUpdateChecking.set(true) try { - const res = await checkHermesUpdate(true) - const status = mapBackendCheck(res) - $updateStatus.set(status) + const status = mapBackendCheck(await checkHermesUpdate(true)) + $backendUpdateStatus.set(status) maybeNotifyUpdateAvailable(status) return status } catch (error) { - const previous = $updateStatus.get() const fallback: DesktopUpdateStatus = { - supported: previous?.supported ?? true, - branch: previous?.branch, + supported: $backendUpdateStatus.get()?.supported ?? true, error: 'check-failed', message: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() } - $updateStatus.set(fallback) + $backendUpdateStatus.set(fallback) return fallback } finally { - $updateChecking.set(false) + $backendUpdateChecking.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()) { @@ -252,9 +247,6 @@ export async function checkUpdates(): Promise { const status = await bridge.check() $updateStatus.set(status) maybeNotifyUpdateAvailable(status) - // The update check pulls the latest hermes_cli + bundled package metadata - // into place. Re-read the running version so About reflects the now-fresh - // checkout rather than the one captured at process start. void refreshDesktopVersion() return status @@ -278,14 +270,6 @@ 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) { @@ -320,40 +304,32 @@ 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 { +export async function applyBackendUpdate(): Promise { dismissNotification(UPDATE_TOAST_ID) - $updateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Updating backend…' }) + $backendUpdateApply.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 }) + $backendUpdateApply.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…' }) + $backendUpdateApply.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(), + // The dashboard restarts mid-update, dropping this connection — expected, not a failure. + $backendUpdateApply.set({ + ...$backendUpdateApply.get(), applying: false, stage: 'restart', message: 'Backend restarting to load the update…' @@ -368,8 +344,8 @@ async function applyBackendUpdate(): Promise { } const ok = !!last && (last.exit_code ?? 1) === 0 - $updateApply.set({ - ...$updateApply.get(), + $backendUpdateApply.set({ + ...$backendUpdateApply.get(), applying: false, stage: ok ? 'restart' : 'error', error: ok ? null : 'apply-failed', @@ -381,7 +357,7 @@ async function applyBackendUpdate(): Promise { : { 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 }) + $backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message }) return { ok: false, error: 'apply-failed', message } }