Compare commits

...

3 Commits

Author SHA1 Message Date
Brooklyn Nicholson
5225fddaef feat(desktop): remote gateway self-restart after /update
Add a gateway.restart RPC that re-execs the backend host in place
(os.execv, no external supervisor needed) so a freshly-pulled remote
checkout actually loads its new code. After a clean update.start the
desktop drives gateway.restart and rides the disconnect out via its
reconnect loop, surfacing a "restarting → reconnecting → done" pill.
Best-effort: an older backend without the RPC falls back to a manual
restart hint; managed installs are refused.
2026-06-05 20:11:45 -05:00
Brooklyn Nicholson
a1cb18b268 feat(desktop): self-update a remote backend from /update
For a remote backend the Electron updater can't help (it patches the local
checkout), so /update now tells the gateway to update its own host. Adds
update.start / update.status RPCs to tui_gateway that spawn `hermes update`
detached (setsid, namespaced .desktop_update_* markers, no --gateway so the
headless run stays non-interactive). The renderer drives a lightweight status
pill that polls across the disconnect/reconnect: starting → running →
reconnecting → done/error, tolerating the backend dropping to restart.

Local backends still open the native updater overlay.
2026-06-05 20:04:54 -05:00
Brooklyn Nicholson
3ed71c09ab feat(desktop): wire /update to the native updater for local backends
The desktop app had /update bucketed into TERMINAL_ONLY_COMMANDS, so typing
it just printed "not available" even though the app ships a full native
updater (openUpdatesWindow / Electron updates bridge). Route /update to that
overlay when the window drives a local backend, and show a clear message for
remote backends (the Electron updater patches the local checkout, not the
remote host). Surface /update in the slash palette.

Also completes the @/hermes mock in the prompt-actions test (spread the real
module) so the file imports again after profile.ts began calling
setApiRequestProfile at module init.
2026-06-05 19:55:01 -05:00
8 changed files with 774 additions and 4 deletions

View File

@@ -3,13 +3,32 @@ import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessions, setSessions } from '@/store/session'
import type { HermesConnection } from '@/global'
import type * as HermesModule from '@/hermes'
import { startRemoteUpdate } from '@/store/remote-update'
import { $sessions, setConnection, setSessions } from '@/store/session'
import { openUpdatesWindow } from '@/store/updates'
import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
vi.mock('@/hermes', () => ({
transcribeAudio: vi.fn()
// Spread the real module (its functions are import-safe; side effects only run
// when called) and override only the network-touching transcribeAudio. A
// hand-listed partial mock breaks as soon as another store imported into this
// graph (e.g. profile.ts → setApiRequestProfile at module init) reaches for a
// new @/hermes export.
vi.mock('@/hermes', async importOriginal => {
const actual = await importOriginal<typeof HermesModule>()
return { ...actual, transcribeAudio: vi.fn() }
})
vi.mock('@/store/updates', () => ({
openUpdatesWindow: vi.fn()
}))
vi.mock('@/store/remote-update', () => ({
startRemoteUpdate: vi.fn()
}))
// The active id the desktop holds is the *runtime* session id from
@@ -90,6 +109,7 @@ describe('usePromptActions /title', () => {
it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) =>
(method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never
)
@@ -113,6 +133,7 @@ describe('usePromptActions /title', () => {
it('reports the queued state when the session row is not persisted yet', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) =>
(method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never
)
@@ -146,6 +167,7 @@ describe('usePromptActions /title', () => {
it('surfaces a rename error without touching the sidebar store', async () => {
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.title') {
throw new Error('Title too long')
@@ -164,3 +186,48 @@ describe('usePromptActions /title', () => {
expect($sessions.get()[0]?.title).toBe('Old title')
})
})
describe('usePromptActions /update', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
setConnection(() => null)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.mocked(openUpdatesWindow).mockClear()
vi.mocked(startRemoteUpdate).mockClear()
setConnection(() => null)
})
it('opens the native updater overlay for a local backend (no slash worker)', async () => {
setConnection(() => ({ mode: 'local' }) as HermesConnection)
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/update')
expect(openUpdatesWindow).toHaveBeenCalledTimes(1)
expect(startRemoteUpdate).not.toHaveBeenCalled()
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('triggers a remote self-update (not the local overlay) for a remote backend', async () => {
setConnection(() => ({ mode: 'remote' }) as HermesConnection)
const refreshSessions = vi.fn(async () => undefined)
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await handle!.submitText('/update')
expect(startRemoteUpdate).toHaveBeenCalledTimes(1)
expect(openUpdatesWindow).not.toHaveBeenCalled()
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
})

View File

@@ -31,8 +31,10 @@ import {
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { startRemoteUpdate } from '@/store/remote-update'
import {
$busy,
$connection,
$messages,
$yoloActive,
setAwaitingResponse,
@@ -41,6 +43,7 @@ import {
setSessions,
setYoloActive
} from '@/store/session'
import { openUpdatesWindow } from '@/store/updates'
import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types'
@@ -444,6 +447,24 @@ export function usePromptActions({
return
}
// /update has two rails. Local backend: the desktop's native (Electron)
// updater overlay, which patches the local checkout. Remote backend: the
// gateway can't be reached by the local updater, so tell it to update
// itself (update.start) and track it with a status pill that survives the
// restart/reconnect. Either way we avoid the CLI's interactive modal,
// which can't run headless in the slash worker.
if (normalizedName === 'update') {
if ($connection.get()?.mode === 'remote') {
void startRemoteUpdate(requestGateway)
return
}
openUpdatesWindow()
return
}
// /profile selects which profile new chats open in — no app relaunch.
// A profile is per-session now, so an existing thread can't change its
// profile mid-stream; `/profile <name>` instead points the next new chat
@@ -531,6 +552,7 @@ export function usePromptActions({
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true

View File

@@ -25,6 +25,11 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashCommand('/my-skill')).toBe(true)
})
it('surfaces /update so the desktop native updater is discoverable', () => {
expect(isDesktopSlashSuggestion('/update')).toBe(true)
expect(isDesktopSlashCommand('/update')).toBe(true)
})
it('hides terminal, messaging, and dedicated-UI commands from suggestions', () => {
expect(isDesktopSlashSuggestion('/clear')).toBe(false)
expect(isDesktopSlashSuggestion('/compact')).toBe(false)

View File

@@ -42,6 +42,7 @@ const DESKTOP_COMMAND_META = [
['/stop', 'Stop running background processes'],
['/title', 'Rename the current session'],
['/undo', 'Remove the last user/assistant exchange'],
['/update', 'Update Hermes to the latest version'],
['/usage', 'Show token usage for this session'],
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
] as const
@@ -98,7 +99,6 @@ const TERMINAL_ONLY_COMMANDS = new Set([
'/statusbar',
'/toolsets',
'/tools',
'/update',
'/verbose'
])

View File

@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $remoteUpdate, resetRemoteUpdate, startRemoteUpdate } from './remote-update'
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
dismissNotification: vi.fn()
}))
const POLL_STEP = 2_100
describe('startRemoteUpdate', () => {
beforeEach(() => {
vi.useFakeTimers()
resetRemoteUpdate()
})
afterEach(() => {
vi.useRealTimers()
})
it('runs starting → running → restarting → done on a clean update + restart', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true }) // update.start
.mockResolvedValueOnce({ running: true, finished: false, exit_code: null, output: '' }) // update.status
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: 'done' }) // update.status
.mockResolvedValueOnce({ restarting: true }) // gateway.restart
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: 'done' }) // update.status (reconnected)
const promise = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(POLL_STEP) // → running
await vi.advanceTimersByTimeAsync(POLL_STEP) // → finished → gateway.restart → wait
await vi.advanceTimersByTimeAsync(POLL_STEP) // → reconnect poll → done
await promise
expect($remoteUpdate.get().phase).toBe('done')
expect($remoteUpdate.get().message).toContain('restarted')
expect(requestGateway).toHaveBeenNthCalledWith(1, 'update.start')
expect(requestGateway).toHaveBeenCalledWith('gateway.restart')
})
it('surfaces an error (with output tail) on a non-zero exit', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 1, output: 'pulling…\nfatal: boom' })
const promise = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(POLL_STEP)
await promise
expect($remoteUpdate.get().phase).toBe('error')
expect($remoteUpdate.get().message).toContain('boom')
expect(requestGateway).not.toHaveBeenCalledWith('gateway.restart')
})
it('shows reconnecting while the backend is down mid-update, then restarts', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true })
.mockRejectedValueOnce(new Error('connection closed'))
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
.mockResolvedValueOnce({ restarting: true })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
const promise = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(POLL_STEP)
expect($remoteUpdate.get().phase).toBe('reconnecting')
await vi.advanceTimersByTimeAsync(POLL_STEP) // → finished → restart → wait
await vi.advanceTimersByTimeAsync(POLL_STEP) // → reconnect poll → done
await promise
expect($remoteUpdate.get().phase).toBe('done')
})
it('treats a dropped transport on gateway.restart as the restart succeeding', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
.mockRejectedValueOnce(new Error('Hermes gateway connection closed')) // gateway.restart drops
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' }) // reconnected
const promise = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(POLL_STEP) // → finished → gateway.restart (drops) → wait
await vi.advanceTimersByTimeAsync(POLL_STEP) // → reconnect poll → done
await promise
expect($remoteUpdate.get().phase).toBe('done')
expect($remoteUpdate.get().message).toContain('restarted')
})
it('falls back to a manual-restart hint when the backend lacks gateway.restart', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
.mockRejectedValueOnce(new Error('method not found')) // gateway.restart unsupported (RPC error)
const promise = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(POLL_STEP)
await promise
expect($remoteUpdate.get().phase).toBe('done')
expect($remoteUpdate.get().message).toContain('Restart it')
})
it('errors without polling when the update fails to start', async () => {
const requestGateway = vi.fn().mockRejectedValueOnce(new Error('not a git checkout'))
await startRemoteUpdate(requestGateway)
expect($remoteUpdate.get().phase).toBe('error')
expect(requestGateway).toHaveBeenCalledTimes(1)
})
it('ignores a second start while one is already in flight', async () => {
const requestGateway = vi
.fn()
.mockResolvedValueOnce({ started: true })
.mockResolvedValueOnce({ running: true, finished: false, exit_code: null, output: '' })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
.mockResolvedValueOnce({ restarting: true })
.mockResolvedValueOnce({ running: false, finished: true, exit_code: 0, output: '' })
const first = startRemoteUpdate(requestGateway)
await vi.advanceTimersByTimeAsync(0)
await startRemoteUpdate(requestGateway) // ignored — a run is already active
await vi.advanceTimersByTimeAsync(POLL_STEP)
await vi.advanceTimersByTimeAsync(POLL_STEP)
await vi.advanceTimersByTimeAsync(POLL_STEP)
await first
const startCalls = requestGateway.mock.calls.filter(call => call[0] === 'update.start')
expect(startCalls).toHaveLength(1)
})
})

View File

@@ -0,0 +1,191 @@
/**
* Remote backend self-update. When a desktop window drives a backend on
* another host, the Electron native updater can't reach it — only the remote
* gateway can update its own box. This kicks off `update.start` on the gateway,
* polls `update.status` until the checkout is rewritten, then asks the gateway
* to re-exec itself (`gateway.restart`) so the new code actually loads — riding
* out the disconnect and surfacing a lightweight status pill (a persistent
* notification) throughout.
*/
import { atom } from 'nanostores'
import { dismissNotification, notify } from '@/store/notifications'
export type RemoteUpdatePhase =
| 'idle'
| 'starting'
| 'running'
| 'restarting'
| 'reconnecting'
| 'done'
| 'error'
export interface RemoteUpdateState {
phase: RemoteUpdatePhase
message: string
}
interface RemoteUpdateStatus {
running: boolean
finished: boolean
exit_code: number | null
output: string
}
type RequestGateway = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
const TOAST_ID = 'remote-backend-update'
const POLL_INTERVAL_MS = 2_000
const POLL_TIMEOUT_MS = 30 * 60 * 1_000
// The backend drops while it re-execs; give it generous room to come back
// before we stop driving the pill (the gateway keeps reconnecting regardless).
const RESTART_TIMEOUT_MS = 3 * 60 * 1_000
const IDLE: RemoteUpdateState = { phase: 'idle', message: '' }
const ACTIVE_PHASES: ReadonlySet<RemoteUpdatePhase> = new Set([
'starting',
'running',
'restarting',
'reconnecting'
])
export const $remoteUpdate = atom<RemoteUpdateState>(IDLE)
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
function setPhase(phase: RemoteUpdatePhase, message: string): void {
$remoteUpdate.set({ phase, message })
if (phase === 'error') {
notify({ id: TOAST_ID, kind: 'error', title: 'Backend update', message, durationMs: 0 })
} else if (phase === 'done') {
notify({ id: TOAST_ID, kind: 'success', title: 'Backend update', message })
} else if (ACTIVE_PHASES.has(phase)) {
notify({ id: TOAST_ID, kind: 'info', title: 'Backend update', message, durationMs: 0 })
} else {
dismissNotification(TOAST_ID)
}
}
export function resetRemoteUpdate(): void {
dismissNotification(TOAST_ID)
$remoteUpdate.set(IDLE)
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
// A dropped transport (the backend re-execing) vs. an RPC-level error (e.g. an
// older backend that doesn't know `gateway.restart`). Only the former means a
// restart is actually underway.
function isConnectionError(error: unknown): boolean {
return /not connected|connection closed|could not connect|timed out|timeout/i.test(errorText(error))
}
function tail(output: string, lines = 4): string {
const trimmed = (output || '').trim()
return trimmed ? trimmed.split('\n').slice(-lines).join('\n') : ''
}
export async function startRemoteUpdate(requestGateway: RequestGateway): Promise<void> {
if (ACTIVE_PHASES.has($remoteUpdate.get().phase)) {
return
}
setPhase('starting', 'Starting backend update…')
try {
await requestGateway('update.start')
} catch (error) {
setPhase('error', errorText(error) || 'Could not start the backend update.')
return
}
setPhase('running', 'Updating remote backend…')
await pollRemoteUpdate(requestGateway)
}
async function pollRemoteUpdate(requestGateway: RequestGateway): Promise<void> {
const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) {
await delay(POLL_INTERVAL_MS)
let status: RemoteUpdateStatus
try {
status = await requestGateway<RemoteUpdateStatus>('update.status')
} catch {
// The backend likely dropped to restart with the new code. requestGateway
// already attempted a reconnect; reflect that and keep polling.
setPhase('reconnecting', 'Reconnecting to backend…')
continue
}
if ($remoteUpdate.get().phase === 'reconnecting') {
setPhase('running', 'Updating remote backend…')
}
if (status.finished) {
if ((status.exit_code ?? 1) === 0) {
await restartRemoteBackend(requestGateway)
} else {
setPhase('error', tail(status.output) || 'Backend update failed.')
}
return
}
}
setPhase('error', 'Backend update timed out.')
}
// The checkout is updated but the process is still running old code. Ask the
// gateway to re-exec itself, then ride out the disconnect until it answers
// again. Best-effort: a backend that can't restart (managed install, or an
// older build without the RPC) just tells the user to restart it by hand.
async function restartRemoteBackend(requestGateway: RequestGateway): Promise<void> {
setPhase('restarting', 'Restarting backend to load the update…')
let restarting = true
try {
await requestGateway('gateway.restart')
} catch (error) {
// A dropped transport is the success signal — the backend re-execed before
// (or while) replying. Any other error means the restart never happened.
restarting = isConnectionError(error)
if (!restarting) {
setPhase('done', 'Backend updated. Restart it to load the new version.')
return
}
}
await waitForReconnect(requestGateway)
}
async function waitForReconnect(requestGateway: RequestGateway): Promise<void> {
const deadline = Date.now() + RESTART_TIMEOUT_MS
while (Date.now() < deadline) {
await delay(POLL_INTERVAL_MS)
try {
await requestGateway<RemoteUpdateStatus>('update.status')
setPhase('done', 'Backend updated and restarted.')
return
} catch {
setPhase('reconnecting', 'Reconnecting to backend…')
}
}
setPhase('done', 'Backend updated. Reconnect once the backend is back.')
}

View File

@@ -0,0 +1,142 @@
"""Tests for the desktop remote self-update RPCs in tui_gateway.
``update.start`` spawns ``hermes update`` detached on the gateway's own host
(so a desktop window driving a REMOTE backend can update that box), and
``update.status`` reports progress via the namespaced ``.desktop_update_*``
marker files. See the /update desktop slash command + ``store/remote-update.ts``.
"""
from __future__ import annotations
import importlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
yield home
@pytest.fixture()
def server(hermes_home):
with patch.dict(
"sys.modules",
{
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
},
):
mod = importlib.import_module("tui_gateway.server")
yield mod
mod._methods.clear()
importlib.reload(mod)
def _call(server, method, **params):
return server._methods[method](1, params)
def test_status_is_idle_with_no_markers(server):
result = _call(server, "update.status")["result"]
assert result == {
"running": False,
"finished": False,
"exit_code": None,
"output": "",
}
def test_status_reports_finished_with_exit_code(server):
server._DESKTOP_UPDATE_OUTPUT.write_text("pulling…\nAlready up to date.")
server._DESKTOP_UPDATE_EXIT_CODE.write_text("0")
result = _call(server, "update.status")["result"]
assert result["finished"] is True
assert result["exit_code"] == 0
assert result["running"] is False
assert "up to date" in result["output"]
def test_status_running_when_pending_without_exit(server):
server._DESKTOP_UPDATE_PENDING.write_text("{}")
result = _call(server, "update.status")["result"]
assert result["running"] is True
assert result["finished"] is False
def test_start_spawns_detached_and_writes_pending(server):
with patch.object(server.subprocess, "Popen") as popen:
result = _call(server, "update.start")["result"]
assert result["started"] is True
assert server._DESKTOP_UPDATE_PENDING.exists()
# Cleared so a stale prior result can't be read as this run's status.
assert not server._DESKTOP_UPDATE_EXIT_CODE.exists()
popen.assert_called_once()
def test_start_is_idempotent_while_running(server):
server._DESKTOP_UPDATE_PENDING.write_text("{}") # in flight, no exit code yet
with patch.object(server.subprocess, "Popen") as popen:
result = _call(server, "update.start")["result"]
assert result.get("already_running") is True
popen.assert_not_called()
def test_start_blocked_on_managed_install(server):
with patch("hermes_cli.config.is_managed", return_value=True):
resp = _call(server, "update.start")
assert "error" in resp
assert "managed" in resp["error"]["message"].lower()
# ── gateway.restart ──────────────────────────────────────────────────────
def test_restart_schedules_reexec_thread(server):
with patch.object(server.threading, "Thread") as thread:
result = _call(server, "gateway.restart")["result"]
assert result["restarting"] is True
thread.assert_called_once()
assert thread.call_args.kwargs.get("daemon") is True
def test_restart_reexecs_in_place_on_posix(server):
captured: dict = {}
class _FakeThread:
def __init__(self, target=None, **_kw):
captured["target"] = target
def start(self): # noqa: D401 - thread shim
pass
with patch.object(server.threading, "Thread", _FakeThread):
_call(server, "gateway.restart")
with patch.object(server.time, "sleep"), patch.object(
server.os, "execv"
) as execv, patch.object(server.sys, "platform", "linux"):
captured["target"]()
execv.assert_called_once()
def test_restart_blocked_on_managed_install(server):
with patch("hermes_cli.config.is_managed", return_value=True):
resp = _call(server, "gateway.restart")
assert "error" in resp
assert "managed" in resp["error"]["message"].lower()

View File

@@ -8398,3 +8398,199 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5002, "command timed out (30s)")
except Exception as e:
return _err(rid, 5003, str(e))
# ── Methods: update.start / update.status ────────────────────────────
# Self-update for a REMOTE backend. The desktop app's native (Electron) updater
# can only patch the LOCAL checkout, so when a desktop window drives a backend
# on another host the only way to update THAT box is to have the gateway run
# `hermes update` on itself — mirroring the messaging gateway's /update. We
# spawn it detached (setsid) so it survives any restart, and the desktop polls
# update.status across the disconnect/reconnect. Markers are namespaced
# (.desktop_update_*) so a co-running messaging gateway's update watcher/cleanup
# never collides with ours.
_DESKTOP_UPDATE_PENDING = _hermes_home / ".desktop_update_pending.json"
_DESKTOP_UPDATE_OUTPUT = _hermes_home / ".desktop_update_output.txt"
_DESKTOP_UPDATE_EXIT_CODE = _hermes_home / ".desktop_update_exit_code"
def _resolve_update_hermes_bin() -> "list[str] | None":
"""Resolve `hermes` as argv parts: PATH shim first, module fallback."""
import shutil
hermes_bin = shutil.which("hermes")
if hermes_bin:
return [hermes_bin]
try:
import importlib.util
if importlib.util.find_spec("hermes_cli") is not None:
return [sys.executable, "-m", "hermes_cli.main"]
except Exception:
pass
return None
@method("update.start")
def _(rid, params: dict) -> dict:
import json as _json
import shlex as _shlex
import shutil as _shutil
from datetime import datetime
try:
from hermes_cli.config import is_managed
if is_managed():
return _err(rid, 4030, "This managed install can't self-update.")
except Exception:
pass
project_root = Path(__file__).resolve().parent.parent
if not (project_root / ".git").exists():
return _err(rid, 4031, "Backend is not a git checkout; can't self-update.")
hermes_cmd = _resolve_update_hermes_bin()
if not hermes_cmd:
return _err(rid, 4032, "Could not locate the hermes command on the backend.")
# An update already in flight (pending written, no exit code yet): don't
# spawn a second updater — just let the caller poll the existing one.
if _DESKTOP_UPDATE_PENDING.exists() and not _DESKTOP_UPDATE_EXIT_CODE.exists():
return _ok(rid, {"started": True, "already_running": True})
_DESKTOP_UPDATE_EXIT_CODE.unlink(missing_ok=True)
_DESKTOP_UPDATE_OUTPUT.unlink(missing_ok=True)
_tmp = _DESKTOP_UPDATE_PENDING.with_suffix(".tmp")
_tmp.write_text(_json.dumps({"source": "desktop", "timestamp": datetime.now().isoformat()}))
_tmp.replace(_DESKTOP_UPDATE_PENDING)
# Plain `hermes update` (no --gateway): with no TTY it takes the
# non-interactive path (safe config migrations auto-applied, local changes
# handled per `updates.non_interactive_local_changes`). --gateway would wait
# on file-IPC prompts that nothing in the tui_gateway answers, so it'd hang.
try:
if sys.platform == "win32":
import textwrap
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
helper = textwrap.dedent(
"""
import os, subprocess, sys
output_path = sys.argv[1]
exit_code_path = sys.argv[2]
cmd = sys.argv[3:]
env = dict(os.environ)
env["PYTHONUNBUFFERED"] = "1"
with open(output_path, "wb") as f:
proc = subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT, env=env)
rc = proc.wait()
with open(exit_code_path, "w") as f:
f.write(str(rc))
"""
).strip()
subprocess.Popen(
[
sys.executable, "-c", helper,
str(_DESKTOP_UPDATE_OUTPUT), str(_DESKTOP_UPDATE_EXIT_CODE),
*hermes_cmd, "update",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**windows_detach_popen_kwargs(),
)
else:
hermes_cmd_str = " ".join(_shlex.quote(part) for part in hermes_cmd)
update_cmd = (
f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update"
f" > {_shlex.quote(str(_DESKTOP_UPDATE_OUTPUT))} 2>&1; "
f"rc=$?; printf '%s' \"$rc\" > {_shlex.quote(str(_DESKTOP_UPDATE_EXIT_CODE))}"
)
setsid_bin = _shutil.which("setsid")
argv = ([setsid_bin] if setsid_bin else []) + ["bash", "-c", update_cmd]
subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except Exception as e:
_DESKTOP_UPDATE_PENDING.unlink(missing_ok=True)
_DESKTOP_UPDATE_EXIT_CODE.unlink(missing_ok=True)
return _err(rid, 5030, f"Failed to start update: {e}")
return _ok(rid, {"started": True})
@method("update.status")
def _(rid, params: dict) -> dict:
output = ""
try:
if _DESKTOP_UPDATE_OUTPUT.exists():
output = _DESKTOP_UPDATE_OUTPUT.read_text(errors="replace")[-4000:]
except Exception:
output = ""
finished = _DESKTOP_UPDATE_EXIT_CODE.exists()
exit_code = None
if finished:
try:
raw = _DESKTOP_UPDATE_EXIT_CODE.read_text().strip()
exit_code = int(raw) if raw else None
except Exception:
exit_code = None
return _ok(
rid,
{
"running": _DESKTOP_UPDATE_PENDING.exists() and not finished,
"finished": finished,
"exit_code": exit_code,
"output": output,
},
)
@method("gateway.restart")
def _(rid, params: dict) -> dict:
"""Re-exec the backend host so freshly-pulled code is actually loaded.
`update.start` only rewrites the checkout on disk — the running process is
still executing the old code. For a LOCAL backend the desktop's Electron
updater handles the relaunch; for a REMOTE backend nothing else can, so the
gateway restarts itself. We re-exec in place (`os.execv`, same PID) after a
short delay so this RPC's response flushes before the socket drops; the
desktop rides the disconnect out via its reconnect loop and re-polls
`update.status`.
Deployment-agnostic by design: in-place re-exec needs no external
supervisor (systemd, pm2, …) — it works for a bare `hermes` process just as
well as a supervised one. Refused for managed installs, which own their own
lifecycle.
"""
try:
from hermes_cli.config import is_managed
if is_managed():
return _err(rid, 4033, "This managed install can't restart itself.")
except Exception:
pass
def _reexec() -> None:
# Let the JSON-RPC reply (and any in-flight event) flush before the
# transport dies under us.
time.sleep(0.4)
argv = [sys.executable, *sys.argv]
try:
if sys.platform == "win32":
# The console-script shim isn't a real Win32 exe, so os.execv
# can't replace it in place — spawn a fresh process, then exit.
subprocess.Popen(argv)
os._exit(0)
else:
os.execv(sys.executable, argv)
except Exception:
logger.exception("gateway.restart: re-exec failed")
threading.Thread(target=_reexec, name="gateway-restart", daemon=True).start()
return _ok(rid, {"restarting": True})