mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 12:18:44 +08:00
Compare commits
3 Commits
worktree-d
...
bb/desktop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5225fddaef | ||
|
|
a1cb18b268 | ||
|
|
3ed71c09ab |
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
|
||||
147
apps/desktop/src/store/remote-update.test.ts
Normal file
147
apps/desktop/src/store/remote-update.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
191
apps/desktop/src/store/remote-update.ts
Normal file
191
apps/desktop/src/store/remote-update.ts
Normal 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.')
|
||||
}
|
||||
142
tests/tui_gateway/test_desktop_update.py
Normal file
142
tests/tui_gateway/test_desktop_update.py
Normal 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()
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user