diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index 01789e1a67..fb50342e75 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Session } from '@/api/types' const { @@ -29,7 +29,8 @@ vi.mock('@/api/api', () => ({ })) vi.mock('@/runner/controlClient', () => ({ - notifyRunnerSessionStarted: notifyRunnerSessionStartedMock + notifyRunnerSessionStarted: notifyRunnerSessionStartedMock, + getInstalledCliMtimeMs: () => 1_700_000_000_000, })) vi.mock('@/persistence', () => ({ @@ -55,6 +56,7 @@ import { bootstrapExistingSession, bootstrapLazySession, bootstrapSession, + buildMachineMetadata, buildSessionMetadata } from './sessionFactory' @@ -363,3 +365,40 @@ describe('bootstrapSession HAPI_SESSION_ID export', () => { expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hub-session-42') }) }) + +describe('buildMachineMetadata runner-only capabilities', () => { + const originalSupervised = process.env.HAPI_RUNNER_SUPERVISED + + afterEach(() => { + if (originalSupervised === undefined) { + delete process.env.HAPI_RUNNER_SUPERVISED + } else { + process.env.HAPI_RUNNER_SUPERVISED = originalSupervised + } + }) + + it('omits machine RPC capabilities for terminal bootstrap metadata', () => { + delete process.env.HAPI_RUNNER_SUPERVISED + const metadata = buildMachineMetadata() + expect(metadata.capabilities).toBeUndefined() + expect(metadata.startedCliMtimeMs).toBeUndefined() + expect(metadata.installedCliMtimeMs).toBeUndefined() + expect(metadata.supervisedRestart).toBeUndefined() + }) + + it('advertises capabilities and supervisedRestart only for asRunner', () => { + process.env.HAPI_RUNNER_SUPERVISED = '1' + const metadata = buildMachineMetadata({ asRunner: true, startedCliMtimeMs: 42 }) + expect(metadata.capabilities).toEqual(expect.arrayContaining(['cursor-chat-store-status', 'stop-runner'])) + expect(metadata.startedCliMtimeMs).toBe(42) + expect(metadata.installedCliMtimeMs).toBe(1_700_000_000_000) + expect(metadata.supervisedRestart).toBe(true) + }) + + it('always sends supervisedRestart boolean for asRunner so sticky true can clear', () => { + delete process.env.HAPI_RUNNER_SUPERVISED + const metadata = buildMachineMetadata({ asRunner: true }) + expect(metadata.capabilities).toEqual(expect.arrayContaining(['stop-runner'])) + expect(metadata.supervisedRestart).toBe(false) + }) +}) diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index c7fbbd1b7a..e42e767abc 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -5,13 +5,14 @@ import { resolve } from 'node:path' import { ApiClient } from '@/api/api' import type { ApiSessionClient } from '@/api/apiSession' import type { AgentState, MachineMetadata, Metadata, Session } from '@/api/types' -import { notifyRunnerSessionStarted } from '@/runner/controlClient' +import { getInstalledCliMtimeMs, notifyRunnerSessionStarted } from '@/runner/controlClient' import { readSettings } from '@/persistence' import { configuration } from '@/configuration' import { logger } from '@/ui/logger' import { runtimePath } from '@/projectPath' import { getInvokedCwd } from '@/utils/invokedCwd' import { readWorktreeEnv } from '@/utils/worktreeEnv' +import { CURRENT_MACHINE_CAPABILITIES } from '@hapi/protocol/runnerCapabilities' import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv' import packageJson from '../../package.json' @@ -41,15 +42,38 @@ export type SessionBootstrapResult = { workingDirectory: string } -export function buildMachineMetadata(options?: { workspaceRoots?: string[] }): MachineMetadata { - return { +export function buildMachineMetadata(options?: { + workspaceRoots?: string[] + startedCliMtimeMs?: number + /** + * Only the long-lived runner daemon may advertise machine RPC capabilities + * and CLI mtimes. Terminal/lazy/existing session bootstraps must omit this + * so a newer CLI session cannot paint an old connected runner as current + * (#1108 bot Major). + */ + asRunner?: boolean +}): MachineMetadata { + const installedCliMtimeMs = getInstalledCliMtimeMs() + const startedCliMtimeMs = options?.startedCliMtimeMs ?? installedCliMtimeMs + const base: MachineMetadata = { host: process.env.HAPI_HOSTNAME || os.hostname(), platform: os.platform(), happyCliVersion: packageJson.version, homeDir: os.homedir(), happyHomeDir: configuration.happyHomeDir, happyLibDir: runtimePath(), - workspaceRoots: options?.workspaceRoots + workspaceRoots: options?.workspaceRoots, + } + if (!options?.asRunner) { + return base + } + return { + ...base, + capabilities: [...CURRENT_MACHINE_CAPABILITIES], + ...(typeof startedCliMtimeMs === 'number' ? { startedCliMtimeMs } : {}), + ...(typeof installedCliMtimeMs === 'number' ? { installedCliMtimeMs } : {}), + // Always boolean so hub merge can clear a prior true on unsupervised restart. + supervisedRestart: process.env.HAPI_RUNNER_SUPERVISED === '1', } } diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index dc5bdf8329..2d7a6632a9 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -24,6 +24,7 @@ import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import { RUNNER_CAPABILITIES } from '@hapi/protocol' import type { RunnerState, Machine, MachineMetadata } from './types' import { RunnerStateSchema, MachineMetadataSchema } from './types' +import { getInstalledCliMtimeMs } from '@/runner/controlClient' import { backoff } from '@/utils/time' import { getInvokedCwd } from '@/utils/invokedCwd' import { RpcHandlerManager } from './rpc/RpcHandlerManager' @@ -656,6 +657,19 @@ export class ApiMachineClient { time: Date.now(), health: collectMachineHealth() }) + const installedCliMtimeMs = getInstalledCliMtimeMs() + if ( + typeof installedCliMtimeMs === 'number' + && this.machine.metadata + && this.machine.metadata.installedCliMtimeMs !== installedCliMtimeMs + ) { + void this.updateMachineMetadata((current) => ({ + ...(current ?? this.machine.metadata!), + installedCliMtimeMs, + })).catch((error) => { + logger.debug('[API MACHINE] Failed to refresh installedCliMtimeMs', error) + }) + } } // Prime CPU sampling so the first heartbeat already includes CPU %. collectMachineHealth() diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 8de1db3f30..60b76d450a 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1121,7 +1121,11 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const machine = await withRetry( () => api.getOrCreateMachine({ machineId, - metadata: buildMachineMetadata({ workspaceRoots }), + metadata: buildMachineMetadata({ + workspaceRoots, + startedCliMtimeMs: startedWithCliMtimeMs, + asRunner: true, + }), runnerState: initialRunnerState }), { diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 91a17073fd..3eb470976c 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -136,7 +136,9 @@ npm install -g pm2 # Start hub and runner pm2 start "hapi hub --relay" --name hapi-hub -pm2 start "hapi runner start-sync" --name hapi-runner +# HAPI_RUNNER_SUPERVISED=1 lets the web Restart button stop the runner knowing +# pm2 will cold-start it again (unsupervised stop would leave the host offline). +HAPI_RUNNER_SUPERVISED=1 pm2 start "hapi runner start-sync" --name hapi-runner # View status and logs pm2 status @@ -196,6 +198,11 @@ Create plist files for automatic startup on macOS. runner start-sync + EnvironmentVariables + + HAPI_RUNNER_SUPERVISED + 1 + RunAtLoad KeepAlive @@ -259,6 +266,9 @@ After=network.target hapi-hub.service [Service] Type=simple KillMode=process +# Advertise supervisedRestart so the web UI Restart button may stop-runner +# knowing systemd will cold-start the unit again. +Environment=HAPI_RUNNER_SUPERVISED=1 ExecStart=/usr/local/bin/hapi runner start-sync Restart=always RestartSec=5 diff --git a/docs/guide/installation.md b/docs/guide/installation.md index e208dc31bd..62c48ac8f5 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -324,7 +324,11 @@ Use `--workspace-root ` to restrict which directories the runner can brows hapi runner start --workspace-root ~/projects --workspace-root ~/work ``` -For running the hub and runner as persistent background services (pm2, launchd, systemd), see [Deployment](./deployment.md). +For running the hub and runner as persistent background services (pm2, launchd, systemd), see [Deployment](./deployment.md). Supervised installs should set `HAPI_RUNNER_SUPERVISED=1` on the runner process (systemd `Environment=` / pm2 `--env`) so the web **Restart** control can safely stop-runner knowing the supervisor will cold-start it. + +### Multi-machine hubs + +You can run **one hub** and **runners on many machines** (each machine installs its own CLI). When you upgrade the hub, upgrade the HAPI CLI on every machine that parents sessions. After the CLI binary on disk changes, that machine’s runner normally **self-restarts** via version handoff (unless `HAPI_DISABLE_VERSION_HANDOFF=1`). Until a runner reports the capabilities the hub requires, the web UI shows a **Runner out of date** banner (minimizable / snoozeable) with the host name and upgrade steps. The banner’s per-host **Restart** is only an escape hatch when handoff is stuck or disabled — the hub never downloads or installs packages on remotes. ## Security notes diff --git a/hub/src/store/machines.test.ts b/hub/src/store/machines.test.ts index 0398278071..fdba2f3675 100644 --- a/hub/src/store/machines.test.ts +++ b/hub/src/store/machines.test.ts @@ -49,6 +49,85 @@ describe('mergeMachineMetadata', () => { it('returns undefined when the merge is a no-op', () => { expect(mergeMachineMetadata({ host: 'a' }, { host: 'a' })).toBeUndefined() }) + + it('clears omitted runner ads when clearOmittedRunnerAds is set', () => { + const merged = mergeMachineMetadata( + { + host: 'box', + capabilities: ['stop-runner'], + supervisedRestart: true, + startedCliMtimeMs: 1, + installedCliMtimeMs: 2, + displayName: 'keep-me', + }, + { host: 'box', supervisedRestart: false }, + { clearOmittedRunnerAds: true }, + ) + expect(merged).toEqual({ + host: 'box', + supervisedRestart: false, + displayName: 'keep-me', + }) + }) + + it('keeps sticky runner ads without clearOmittedRunnerAds (terminal bootstrap)', () => { + const merged = mergeMachineMetadata( + { host: 'box', capabilities: ['stop-runner'], supervisedRestart: true }, + { host: 'box' }, + ) + expect(merged).toBeUndefined() + }) +}) + +describe('runner metadata ad clear on re-registration', () => { + it('drops sticky supervisedRestart and capabilities when runner re-registers without them', () => { + const store = new Store(':memory:') + store.machines.getOrCreateMachine( + 'machine-1', + { + host: 'box', + capabilities: ['stop-runner'], + supervisedRestart: true, + startedCliMtimeMs: 10, + }, + { status: 'running', pid: 1 }, + 'ns', + ) + + const refreshed = store.machines.getOrCreateMachine( + 'machine-1', + { host: 'box', supervisedRestart: false }, + { status: 'running', pid: 2 }, + 'ns', + ) + + expect(refreshed.metadata).toEqual({ host: 'box', supervisedRestart: false }) + expect(refreshed.metadata).not.toHaveProperty('capabilities') + expect(refreshed.metadata).not.toHaveProperty('startedCliMtimeMs') + }) + + it('does not clear runner ads on terminal-only metadata refresh (no runnerState)', () => { + const store = new Store(':memory:') + store.machines.getOrCreateMachine( + 'machine-1', + { host: 'box', capabilities: ['stop-runner'], supervisedRestart: true }, + { status: 'running', pid: 1 }, + 'ns', + ) + + const refreshed = store.machines.getOrCreateMachine( + 'machine-1', + { host: 'box' }, + null, + 'ns', + ) + + expect(refreshed.metadata).toEqual({ + host: 'box', + capabilities: ['stop-runner'], + supervisedRestart: true, + }) + }) }) describe('runner capabilities backfill', () => { diff --git a/hub/src/store/machines.ts b/hub/src/store/machines.ts index 892f5a39fb..6e8f4c50d4 100644 --- a/hub/src/store/machines.ts +++ b/hub/src/store/machines.ts @@ -44,10 +44,33 @@ function isPlainObject(value: unknown): value is Record { // machine-owned fields over the stored ones so registration doubles as a // refresh; hub-side fields the CLI never sends (e.g. displayName) survive. // Returns undefined when the merge would not change anything. -export function mergeMachineMetadata(stored: unknown, incoming: unknown): Record | undefined { +// +// When `clearOmittedRunnerAds` is set (full runner daemon registration with +// runnerState), runner-advertised keys omitted from incoming are deleted so +// rollback / unsupervised restart cannot leave sticky capabilities or +// supervisedRestart:true (#1108 bot Major). +export const RUNNER_ADVERTISED_METADATA_KEYS = [ + 'capabilities', + 'supervisedRestart', + 'startedCliMtimeMs', + 'installedCliMtimeMs', +] as const + +export function mergeMachineMetadata( + stored: unknown, + incoming: unknown, + options?: { clearOmittedRunnerAds?: boolean }, +): Record | undefined { if (!isPlainObject(incoming)) return undefined const base = isPlainObject(stored) ? stored : {} - const merged = { ...base, ...incoming } + const merged: Record = { ...base, ...incoming } + if (options?.clearOmittedRunnerAds) { + for (const key of RUNNER_ADVERTISED_METADATA_KEYS) { + if (!(key in incoming)) { + delete merged[key] + } + } + } return JSON.stringify(merged) === JSON.stringify(base) ? undefined : merged } @@ -82,7 +105,11 @@ export function getOrCreateMachine( if (stored.namespace !== namespace) { throw new Error('Machine namespace mismatch') } - const merged = mergeMachineMetadata(stored.metadata, metadata) + const merged = mergeMachineMetadata(stored.metadata, metadata, { + // Full runner registration (with runnerState) owns the skew ads — + // omit means clear, so rollback cannot leave sticky supervisedRestart. + clearOmittedRunnerAds: runnerState !== null && runnerState !== undefined, + }) let current = stored if (merged !== undefined) { db.prepare(` diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 259155a8c4..405dadafc7 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -279,6 +279,10 @@ export class RpcGateway { return CursorChatStoreStatusSchema.parse(result) } + async stopRunner(machineId: string): Promise { + await this.machineRpc(machineId, RPC_METHODS.StopRunner, {}) + } + async getGitStatus(sessionId: string, cwd?: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.GitStatus, { cwd }) as RpcCommandResponse } diff --git a/hub/src/sync/runnerEnsure.test.ts b/hub/src/sync/runnerEnsure.test.ts new file mode 100644 index 0000000000..02742ee3d2 --- /dev/null +++ b/hub/src/sync/runnerEnsure.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, mock } from 'bun:test' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' + +describe('SyncEngine restartMachineRunner', () => { + it('refuses Restart on unsupervised hosts (stop would leave runner offline)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const stopRunner = mock(async () => undefined) + ;(engine as any).rpcGateway.stopRunner = stopRunner + + engine.getOrCreateMachine( + 'manual-runner', + { host: 'laptop', platform: 'linux', happyCliVersion: '0.20.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'manual-runner', time: Date.now() }) + + const result = await engine.restartMachineRunner('manual-runner', 'default') + expect(result.type).toBe('error') + if (result.type === 'error') { + expect(result.code).toBe('restart_unsupported') + } + expect(stopRunner).not.toHaveBeenCalled() + } finally { + engine.stop() + } + }) + + it('stop-runners for a supervised online machine (banner escape hatch)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const stopRunner = mock(async () => undefined) + ;(engine as any).rpcGateway.stopRunner = stopRunner + + engine.getOrCreateMachine( + 'supervised-runner', + { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + supervisedRestart: true, + }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'supervised-runner', time: Date.now() }) + + const result = await engine.restartMachineRunner('supervised-runner', 'default') + expect(result).toEqual({ + type: 'success', + message: 'Runner stop requested; supervisor will relaunch', + }) + expect(stopRunner).toHaveBeenCalledWith('supervised-runner') + } finally { + engine.stop() + } + }) +}) diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 7a3fea136a..9886076d05 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -3226,6 +3226,59 @@ describe('session model', () => { } }) + it('soft-fails Cursor reopen when chat-store probe throws (missing handler / skew)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'cursor-probe-skew-reopen', + { + path: '/tmp/project', + host: 'cursor-host', + machineId: 'cursor-machine', + homeDir: '/home/cursor-owner', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-skew', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'cursor-machine', + { host: 'cursor-host', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'cursor-machine', time: Date.now() }) + + let spawnCalled = false + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => { + throw new Error('RPC handler not registered: cursor-machine:cursor-chat-store-status') + } + ;(engine as any).rpcGateway.spawnSession = async () => { + spawnCalled = true + engine.handleSessionAlive({ sid: session.id, time: Date.now() }) + return { type: 'success', sessionId: session.id } + } + ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionReady = async () => 'ready' + + const result = await engine.resumeSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(spawnCalled).toBe(true) + } finally { + engine.stop() + } + }) + it('probes Cursor chat data on the session recorded machine', async () => { const store = new Store(':memory:') const engine = new SyncEngine( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 92f38fb91d..d74ab078e4 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -8,6 +8,10 @@ */ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession, type SessionEndReason } from '@hapi/protocol' +import { + cliBinaryUpdatedOnDisk, + isMachineCapabilitySkewed, +} from '@hapi/protocol/runnerCapabilities' import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' @@ -196,7 +200,7 @@ export class SyncEngine { private readonly store: Store, private readonly io: Server, rpcRegistry: RpcRegistry, - sseManager: SSEManager + sseManager: SSEManager, ) { this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event)) this.sessionCache = new SessionCache(store, this.eventPublisher) @@ -773,7 +777,7 @@ export class SyncEngine { } } -async uploadScratchlistAttachment( + async uploadScratchlistAttachment( sessionId: string, namespace: string, filename: string, @@ -874,6 +878,42 @@ async uploadScratchlistAttachment( this.machineCache.handleMachineAlive(payload) } + /** + * Manual stop-runner for supervised hosts only (banner Restart). + * Detached `hapi runner start` has no supervisor — stop would leave the + * host offline. Require `metadata.supervisedRestart` (HAPI_RUNNER_SUPERVISED=1). + */ + async restartMachineRunner(machineId: string, namespace: string): Promise< + | { type: 'success'; message: string } + | { type: 'error'; message: string; code: 'machine_not_found' | 'machine_offline' | 'restart_unsupported' | 'restart_failed' } + > { + const machine = this.machineCache.getMachineByNamespace(machineId, namespace) + ?? this.machineCache.refreshMachine(machineId) + if (!machine || machine.namespace !== namespace) { + return { type: 'error', message: 'Machine not found', code: 'machine_not_found' } + } + if (!machine.active) { + return { type: 'error', message: 'Machine is offline', code: 'machine_offline' } + } + if (machine.metadata?.supervisedRestart !== true) { + return { + type: 'error', + message: 'Restart requires a supervised runner (HAPI_RUNNER_SUPERVISED=1); unsupervised stop would leave the host offline', + code: 'restart_unsupported', + } + } + try { + await this.rpcGateway.stopRunner(machineId) + return { type: 'success', message: 'Runner stop requested; supervisor will relaunch' } + } catch (error) { + return { + type: 'error', + message: error instanceof Error ? error.message : 'Failed to restart runner', + code: 'restart_failed', + } + } + } + private expireInactive(): void { const expired = this.sessionCache.expireInactive() // Sort by most recent first so dedup keeps the newest session when multiple @@ -2785,11 +2825,15 @@ async uploadScratchlistAttachment( } } } catch (error) { - return { - type: 'error', - message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store', - code: 'resume_failed' - } + // Soft-fail on probe skew / missing handler (#1084): definitive + // onDisk:false still blocks above; probe errors must not be + // reported as missing chat data. + const message = error instanceof Error ? error.message : 'Failed to inspect Cursor chat store' + console.warn('[resume] Cursor chat-store probe failed; proceeding with reopen attempt', { + sessionId: access.sessionId, + machineId: targetMachine.id, + message + }) } } diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 10abbeaac2..e367b3230d 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -320,5 +320,27 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.post('/machines/:id/restart-runner', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + const result = await engine.restartMachineRunner(machineId, c.get('namespace')) + if (result.type === 'error') { + const status = result.code === 'machine_not_found' ? 404 + : result.code === 'machine_offline' ? 503 + : 502 + return c.json({ error: result.message, code: result.code }, status) + } + return c.json({ message: result.message }) + }) + return app } diff --git a/shared/package.json b/shared/package.json index b1e8edfe28..760d72d22f 100644 --- a/shared/package.json +++ b/shared/package.json @@ -14,6 +14,7 @@ "./conversationHistory": "./src/conversationHistory.ts", "./modes": "./src/modes.ts", "./rpcMethods": "./src/rpcMethods.ts", + "./runnerCapabilities": "./src/runnerCapabilities.ts", "./schemas": "./src/schemas.ts", "./sessionCitation": "./src/sessionCitation.ts", "./sessionExport": "./src/sessionExport.ts", diff --git a/shared/src/index.ts b/shared/src/index.ts index 714001a097..824e0d55c6 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -11,6 +11,7 @@ export * from './models' export * from './modes' export * from './resume' export * from './rpcMethods' +export * from './runnerCapabilities' export * from './socket' export * from './sessionSummary' export * from './sessionCitation' diff --git a/shared/src/runnerCapabilities.test.ts b/shared/src/runnerCapabilities.test.ts new file mode 100644 index 0000000000..d9404b0f4c --- /dev/null +++ b/shared/src/runnerCapabilities.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + CURRENT_MACHINE_CAPABILITIES, + MACHINE_CAPABILITIES, + REQUIRED_MACHINE_CAPABILITIES, + cliBinaryUpdatedOnDisk, + isMachineCapabilitySkewed, + missingRequiredCapabilities, +} from './runnerCapabilities' + +describe('runnerCapabilities', () => { + it('requires cursor-chat-store-status so hub features cannot fail-closed without a registry entry', () => { + expect(REQUIRED_MACHINE_CAPABILITIES).toContain(MACHINE_CAPABILITIES.CursorChatStoreStatus) + expect(CURRENT_MACHINE_CAPABILITIES).toEqual(expect.arrayContaining([ + ...REQUIRED_MACHINE_CAPABILITIES, + ])) + }) + + it('treats missing/empty advertised capabilities as skewed', () => { + expect(isMachineCapabilitySkewed(undefined)).toBe(true) + expect(isMachineCapabilitySkewed(null)).toBe(true) + expect(isMachineCapabilitySkewed([])).toBe(true) + expect(missingRequiredCapabilities([])).toEqual([ + MACHINE_CAPABILITIES.CursorChatStoreStatus, + ]) + }) + + it('is not skewed when required capabilities are advertised', () => { + expect(isMachineCapabilitySkewed([...CURRENT_MACHINE_CAPABILITIES])).toBe(false) + expect(missingRequiredCapabilities([ + MACHINE_CAPABILITIES.CursorChatStoreStatus, + 'other-cap', + ])).toEqual([]) + }) + + it('detects on-disk CLI binary updates via mtime drift', () => { + expect(cliBinaryUpdatedOnDisk({ + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + })).toBe(true) + expect(cliBinaryUpdatedOnDisk({ + startedCliMtimeMs: 100, + installedCliMtimeMs: 100, + })).toBe(false) + expect(cliBinaryUpdatedOnDisk({})).toBe(false) + expect(cliBinaryUpdatedOnDisk(null)).toBe(false) + }) +}) diff --git a/shared/src/runnerCapabilities.ts b/shared/src/runnerCapabilities.ts index 4b2c59b8b5..f51c253eda 100644 --- a/shared/src/runnerCapabilities.ts +++ b/shared/src/runnerCapabilities.ts @@ -1,3 +1,5 @@ +import { RPC_METHODS } from './rpcMethods' + /** * Capabilities the current runner generation advertises to the hub. * @@ -15,3 +17,59 @@ export const RUNNER_CAPABILITIES = { } as const export type RunnerCapabilities = typeof RUNNER_CAPABILITIES + +/** + * Machine-scoped capabilities runners advertise on connect. + * Hub features that hard-depend on a machine RPC must list that capability + * in {@link REQUIRED_MACHINE_CAPABILITIES} so skew surfaces as a banner + * instead of a silent fail-closed product bug. + */ +export const MACHINE_CAPABILITIES = { + CursorChatStoreStatus: RPC_METHODS.CursorChatStoreStatus, + StopRunner: RPC_METHODS.StopRunner, +} as const + +export type MachineCapability = + (typeof MACHINE_CAPABILITIES)[keyof typeof MACHINE_CAPABILITIES] + +/** Capabilities this CLI generation registers on the machine socket. */ +export const CURRENT_MACHINE_CAPABILITIES: readonly MachineCapability[] = [ + MACHINE_CAPABILITIES.CursorChatStoreStatus, + MACHINE_CAPABILITIES.StopRunner, +] + +/** + * Capabilities the hub requires on every connected runner for features it + * hard-depends on. Missing entries → operator-visible skew banner (+ optional + * stop-runner ensure when a newer binary is already on disk). + */ +export const REQUIRED_MACHINE_CAPABILITIES: readonly MachineCapability[] = [ + MACHINE_CAPABILITIES.CursorChatStoreStatus, +] + +export function missingRequiredCapabilities( + advertised: readonly string[] | null | undefined, +): MachineCapability[] { + const set = new Set(advertised ?? []) + return REQUIRED_MACHINE_CAPABILITIES.filter((cap) => !set.has(cap)) +} + +export function isMachineCapabilitySkewed( + advertised: readonly string[] | null | undefined, +): boolean { + return missingRequiredCapabilities(advertised).length > 0 +} + +/** True when the running process started from a different CLI binary/mtime than what's installed now. */ +export function cliBinaryUpdatedOnDisk(metadata: { + startedCliMtimeMs?: number | null + installedCliMtimeMs?: number | null +} | null | undefined): boolean { + const started = metadata?.startedCliMtimeMs + const installed = metadata?.installedCliMtimeMs + return typeof started === 'number' + && typeof installed === 'number' + && Number.isFinite(started) + && Number.isFinite(installed) + && started !== installed +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index dc9288ae31..7c7d3215ea 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -432,7 +432,18 @@ export const MachineMetadataSchema = z.object({ homeDir: z.string().optional(), happyHomeDir: z.string().optional(), happyLibDir: z.string().optional(), - workspaceRoots: z.array(z.string()).optional() + workspaceRoots: z.array(z.string()).optional(), + /** Machine-scoped RPC capability ids this runner registers (see runnerCapabilities). */ + capabilities: z.array(z.string()).optional(), + /** CLI binary/package mtime when this runner process started. */ + startedCliMtimeMs: z.number().optional(), + /** Current on-disk CLI binary/package mtime (may differ after upgrade). */ + installedCliMtimeMs: z.number().optional(), + /** + * Runner is under systemd/pm2 (HAPI_RUNNER_SUPERVISED=1). Banner Restart + * may stop-runner; unsupervised detached runners must not use that path. + */ + supervisedRestart: z.boolean().optional(), }) export type MachineMetadata = z.infer diff --git a/web/src/App.tsx b/web/src/App.tsx index 3f9ea2c394..c71f5cb453 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -30,6 +30,7 @@ import { PwaUpdateBanner, PwaUpdateBannerWithStatusOffset } from '@/components/P import { SyncingBanner } from '@/components/SyncingBanner' import { ReconnectingBanner } from '@/components/ReconnectingBanner' import { VoiceErrorBanner } from '@/components/VoiceErrorBanner' +import { RunnerVersionSkewBanner } from '@/components/RunnerVersionSkewBanner' import { LoadingState } from '@/components/LoadingState' import { ToastContainer } from '@/components/ToastContainer' import { PwaUpdateProvider } from '@/lib/pwa-update-context' @@ -478,6 +479,7 @@ function AppInner() { isHubConnected={globalSubscriptionId !== null} isReconnecting={showReconnectingBanner} /> + diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 08650fdd62..3e5dac619b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -747,6 +747,13 @@ export class ApiClient { return await this.request(`/api/usage/summary?${params.toString()}`) } + async restartMachineRunner(machineId: string): Promise<{ message: string }> { + return await this.request<{ message: string }>( + `/api/machines/${encodeURIComponent(machineId)}/restart-runner`, + { method: 'POST', body: '{}' } + ) + } + async listMachineDirectory( machineId: string, path: string, diff --git a/web/src/components/NewSession/MachineSelector.tsx b/web/src/components/NewSession/MachineSelector.tsx index 1ee076e476..553ddeadb8 100644 --- a/web/src/components/NewSession/MachineSelector.tsx +++ b/web/src/components/NewSession/MachineSelector.tsx @@ -1,4 +1,5 @@ import type { Machine } from '@/types/api' +import { isMachineCapabilitySkewed } from '@hapi/protocol/runnerCapabilities' import { useTranslation } from '@/lib/use-translation' import { SelectControl } from '@/components/ui/select-control' @@ -8,6 +9,18 @@ function getMachineTitle(machine: Machine): string { return machine.id.slice(0, 8) } +function getMachineOptionLabel(machine: Machine, updateRequiredLabel: string): string { + const title = getMachineTitle(machine) + const platform = machine.metadata?.platform ? ` (${machine.metadata.platform})` : '' + const version = machine.metadata?.happyCliVersion + ? ` · CLI ${machine.metadata.happyCliVersion}` + : '' + const skew = machine.active && isMachineCapabilitySkewed(machine.metadata?.capabilities) + ? ` · ${updateRequiredLabel}` + : '' + return `${title}${platform}${version}${skew}` +} + export function MachineSelector(props: { machines: Machine[] machineId: string | null @@ -36,8 +49,7 @@ export function MachineSelector(props: { )} {props.machines.map((m) => ( - {getMachineTitle(m)} - {m.metadata?.platform ? ` (${m.metadata.platform})` : ''} + {getMachineOptionLabel(m, t('runner.skew.updateRequired'))} ))} diff --git a/web/src/components/RunnerVersionSkewBanner.test.tsx b/web/src/components/RunnerVersionSkewBanner.test.tsx new file mode 100644 index 0000000000..33e0041a01 --- /dev/null +++ b/web/src/components/RunnerVersionSkewBanner.test.tsx @@ -0,0 +1,303 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CURRENT_MACHINE_CAPABILITIES } from '@hapi/protocol/runnerCapabilities' +import type { Machine } from '@/types/api' +import { + RunnerVersionSkewBanner, + listSkewedMachines, + machineDisplayHost, +} from './RunnerVersionSkewBanner' +import { I18nProvider } from '@/lib/i18n-context' +import { + clearRunnerSkewTempDismiss, + resetRunnerSkewBannerMemoryForTests, + setRunnerSkewMinimized, +} from '@/lib/runnerSkewBannerState' + +const useMachinesMock = vi.fn() +const restartMachineRunnerMock = vi.fn(async () => ({ message: 'ok' })) +const useAppContextMock = vi.fn(() => ({ + api: { restartMachineRunner: restartMachineRunnerMock } as never, + token: 't', + baseUrl: 'http://localhost', +})) + +vi.mock('@/hooks/queries/useMachines', () => ({ + useMachines: (...args: unknown[]) => useMachinesMock(...args), +})) + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => useAppContextMock(), +})) + +vi.mock('@/hooks/useOnlineStatus', () => ({ + useOnlineStatus: () => true, +})) + +vi.mock('@/hooks/usePlatform', () => ({ + usePlatform: () => ({ + haptic: { impact: vi.fn(), notification: vi.fn() }, + }), +})) + +function makeMachine(overrides: Partial & { id: string }): Machine { + const { id, ...rest } = overrides + return { + id, + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: rest.active ?? true, + activeAt: Date.now(), + metadata: rest.metadata ?? { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 0, + ...rest, + } as Machine +} + +describe('listSkewedMachines', () => { + it('flags online machines without required capabilities', () => { + const skewed = listSkewedMachines([ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + makeMachine({ + id: 'new', + metadata: { + host: 'oos', + platform: 'linux', + happyCliVersion: '0.23.0', + capabilities: [...CURRENT_MACHINE_CAPABILITIES], + }, + }), + makeMachine({ + id: 'offline-old', + active: false, + metadata: { host: 'ha', platform: 'linux', happyCliVersion: '0.19.0' }, + }), + ]) + expect(skewed.map((m) => m.id)).toEqual(['old']) + }) + + it('uses displayName when present', () => { + expect(machineDisplayHost(makeMachine({ + id: 'm1', + metadata: { + host: 'proxmox.local', + platform: 'linux', + happyCliVersion: '0.20.0', + displayName: 'Proxmox box', + }, + }))).toBe('Proxmox box') + }) +}) + +describe('RunnerVersionSkewBanner', () => { + beforeEach(() => { + window.sessionStorage.clear() + resetRunnerSkewBannerMemoryForTests() + setRunnerSkewMinimized(false) + clearRunnerSkewTempDismiss() + restartMachineRunnerMock.mockClear() + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders a compact banner with minimize and snooze actions', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + expect(screen.getByTestId('runner-version-skew-banner')).toHaveAttribute('data-state', 'expanded') + expect(screen.getByText(/1 runner\(s\) out of date/)).toBeInTheDocument() + expect(screen.getByTestId('runner-version-skew-minimize')).toBeInTheDocument() + expect(screen.getByTestId('runner-version-skew-dismiss')).toBeInTheDocument() + expect(screen.getByTestId('runner-version-skew-restart-old')).toBeInTheDocument() + }) + + it('minimizes so the strip stays small', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId('runner-version-skew-minimize')) + expect(screen.getByTestId('runner-version-skew-banner')).toHaveAttribute('data-state', 'minimized') + expect(screen.getByTestId('runner-version-skew-expand')).toBeInTheDocument() + }) + + it('temp-dismisses so sessions are reachable', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId('runner-version-skew-dismiss')) + expect(screen.queryByTestId('runner-version-skew-banner')).not.toBeInTheDocument() + }) + + it('disables Restart when no newer CLI is on disk', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + const restart = screen.getByTestId('runner-version-skew-restart-old') + expect(restart).toBeDisabled() + expect(restart).toHaveTextContent(/Upgrade CLI first/) + }) + + it('disables Restart when newer CLI is on disk but runner is unsupervised', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ + id: 'old', + metadata: { + host: 'laptop', + platform: 'linux', + happyCliVersion: '0.20.0', + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + }, + }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + expect(screen.getByTestId('runner-version-skew-restart-old')).toBeDisabled() + }) + + it('calls restartMachineRunner when Restart is clicked on a supervised host with newer CLI', async () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ + id: 'old', + metadata: { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + supervisedRestart: true, + }, + }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + fireEvent.click(screen.getByTestId('runner-version-skew-restart-old')) + await waitFor(() => { + expect(restartMachineRunnerMock).toHaveBeenCalledWith('old') + }) + }) + it('minimizes even when sessionStorage setItem throws QuotaExceededError', () => { + const proto = Object.getPrototypeOf(window.sessionStorage) as Storage + vi.spyOn(proto, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError') + }) + + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + expect(() => fireEvent.click(screen.getByTestId('runner-version-skew-minimize'))).not.toThrow() + expect(screen.getByTestId('runner-version-skew-banner')).toHaveAttribute('data-state', 'minimized') + }) + + it('hides when all online machines advertise required capabilities', async () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ + id: 'new', + metadata: { + host: 'oos', + platform: 'linux', + happyCliVersion: '0.23.0', + capabilities: [...CURRENT_MACHINE_CAPABILITIES], + }, + }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.queryByTestId('runner-version-skew-banner')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/src/components/RunnerVersionSkewBanner.tsx b/web/src/components/RunnerVersionSkewBanner.tsx new file mode 100644 index 0000000000..7edd76be1d --- /dev/null +++ b/web/src/components/RunnerVersionSkewBanner.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useState } from 'react' +import { isMachineCapabilitySkewed, cliBinaryUpdatedOnDisk } from '@hapi/protocol/runnerCapabilities' +import type { Machine } from '@/types/api' +import { useMachines } from '@/hooks/queries/useMachines' +import { useTranslation } from '@/lib/use-translation' +import { useAppContext } from '@/lib/app-context' +import { useOnlineStatus } from '@/hooks/useOnlineStatus' +import { usePlatform } from '@/hooks/usePlatform' +import { + clearRunnerSkewTempDismiss, + getRunnerSkewDismissUntil, + isRunnerSkewMinimized, + isRunnerSkewTempDismissed, + setRunnerSkewMinimized, + tempDismissRunnerSkew, +} from '@/lib/runnerSkewBannerState' + +export function machineDisplayHost(machine: Machine): string { + return machine.metadata?.displayName + ?? machine.metadata?.host + ?? machine.id +} + +export function listSkewedMachines(machines: Machine[]): Machine[] { + return machines.filter((machine) => ( + machine.active + && isMachineCapabilitySkewed(machine.metadata?.capabilities) + )) +} + +/** + * Compact, minimizable skew banner (#1084 dogfood). + * Temp-dismiss (1h, sessionStorage) or minimize so sessions stay clickable. + * Manual Restart asks hub to stop-runner (escape hatch when version handoff + * is stuck or HAPI_DISABLE_VERSION_HANDOFF=1). Normal upgrades self-restart. + */ +export function RunnerVersionSkewBanner({ topClassName }: { topClassName?: string } = {}) { + const { api } = useAppContext() + const { machines } = useMachines(api, true) + const { t } = useTranslation() + const isOnline = useOnlineStatus() + const { haptic } = usePlatform() + const skewed = listSkewedMachines(machines) + const [minimized, setMinimized] = useState(() => isRunnerSkewMinimized()) + const [dismissed, setDismissed] = useState(() => isRunnerSkewTempDismissed()) + const [restartingId, setRestartingId] = useState(null) + const [restartError, setRestartError] = useState(null) + + useEffect(() => { + if (!dismissed) { + return + } + const remaining = Math.max(0, getRunnerSkewDismissUntil() - Date.now()) + if (remaining === 0) { + clearRunnerSkewTempDismiss() + setDismissed(false) + return + } + const timer = window.setTimeout(() => { + clearRunnerSkewTempDismiss() + setDismissed(false) + }, remaining) + return () => window.clearTimeout(timer) + }, [dismissed]) + + const onMinimize = useCallback(() => { + haptic.impact('light') + // UI first — storage may throw QuotaExceededError on full sessionStorage. + setMinimized(true) + setRunnerSkewMinimized(true) + }, [haptic]) + + const onExpand = useCallback(() => { + haptic.impact('light') + setMinimized(false) + setRunnerSkewMinimized(false) + }, [haptic]) + + const onTempDismiss = useCallback(() => { + haptic.impact('light') + setDismissed(true) + tempDismissRunnerSkew() + }, [haptic]) + + const onRestart = useCallback(async (machine: Machine) => { + if (!api) { + return + } + haptic.impact('medium') + setRestartError(null) + setRestartingId(machine.id) + try { + await api.restartMachineRunner(machine.id) + } catch (error) { + setRestartError(error instanceof Error ? error.message : t('runner.skew.restartFailed')) + } finally { + setRestartingId(null) + } + }, [api, haptic, t]) + + if (skewed.length === 0 || dismissed) { + return null + } + + const topClass = topClassName ?? (isOnline ? 'top-2' : 'top-10') + const hosts = skewed.map(machineDisplayHost).join(', ') + + if (minimized) { + return ( + + + {t('runner.skew.banner.minimized', { count: skewed.length, hosts })} + + + ) + } + + return ( + + + + + {t('runner.skew.banner.summaryTitle', { count: skewed.length })} + + + {t('runner.skew.banner.summaryBody')} + + + + + {t('runner.skew.banner.minimize')} + + + {t('runner.skew.banner.dismissTemp')} + + + + + + {skewed.map((machine) => { + const host = machineDisplayHost(machine) + const version = machine.metadata?.happyCliVersion + const newerOnDisk = cliBinaryUpdatedOnDisk(machine.metadata) + const supervised = machine.metadata?.supervisedRestart === true + const canRestart = newerOnDisk && supervised + const restartBusy = restartingId === machine.id + const restartTitle = !newerOnDisk + ? t('runner.skew.banner.restartNeedsNewerBinary') + : !supervised + ? t('runner.skew.banner.restartNeedsSupervisor') + : undefined + return ( + + + + {host} + {version ? ` · CLI ${version}` : null} + {newerOnDisk ? ( + + {t('runner.skew.banner.binaryUpdatedHint')} + + ) : ( + + {t('runner.skew.banner.upgradeCliFirst')} + + )} + + void onRestart(machine)} + className="shrink-0 rounded bg-amber-900 px-2 py-1 text-xs font-medium text-amber-50 disabled:opacity-50 dark:bg-amber-100 dark:text-amber-950" + > + {restartBusy + ? t('runner.skew.banner.restarting') + : canRestart + ? t('runner.skew.banner.restart') + : t('runner.skew.banner.restartUnavailable')} + + + + ) + })} + + + {restartError ? ( + + {restartError} + + ) : null} + + + {t('runner.skew.banner.handoffHint')} + + + ) +} diff --git a/web/src/components/SessionActionMenu.test.tsx b/web/src/components/SessionActionMenu.test.tsx index 5ac0f5f6a6..f89a4c5960 100644 --- a/web/src/components/SessionActionMenu.test.tsx +++ b/web/src/components/SessionActionMenu.test.tsx @@ -112,6 +112,22 @@ describe('SessionActionMenu - Reopen action', () => { expect(onClose).not.toHaveBeenCalled() }) + it('keeps Reopen enabled with a soft-fail hint when probe is unverified', () => { + const onReopen = vi.fn() + renderMenu({ + sessionActive: false, + onReopen, + reopenHint: 'Could not verify Cursor chat data (runner may be outdated).', + }) + + const reopen = screen.getByRole('menuitem', { name: /Reopen/ }) + expect(reopen).not.toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('tooltip')).toHaveTextContent('Could not verify Cursor chat data') + + fireEvent.click(reopen) + expect(onReopen).toHaveBeenCalledTimes(1) + }) + it('fires onReopen and closes the menu when the Reopen item is clicked', () => { const onReopen = vi.fn() const onClose = vi.fn() diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index f7e66aa906..49ae993486 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -30,6 +30,8 @@ type SessionActionMenuProps = { onArchive: () => void onReopen?: () => void reopenDisabledReason?: string + /** Soft-fail tip when reopen is allowed but chat-store probe could not verify. */ + reopenHint?: string onDelete: () => void anchorPoint: { x: number; y: number } menuId?: string @@ -199,6 +201,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { onArchive, onReopen, reopenDisabledReason, + reopenHint, onDelete, anchorPoint, menuId @@ -456,7 +459,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { ) : ( <> - {onReopen || reopenDisabledReason ? ( + {onReopen || reopenDisabledReason || reopenHint ? ( )} > - {reopenDisabledReason ?? t('session.action.reopen')} + {reopenDisabledReason ?? reopenHint ?? t('session.action.reopen')} ) : null} { await transferComposerDraftThenNavigate( diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 51cb37ca5a..77f6b938da 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -147,6 +147,7 @@ export function SessionHeader(props: { api: ApiClient | null canReopen?: boolean reopenDisabledReason?: string + reopenHint?: string onSessionDeleted?: () => void onSessionReopened?: (newSessionId: string) => void | Promise }) { @@ -522,6 +523,7 @@ export function SessionHeader(props: { onArchive={() => setArchiveOpen(true)} onReopen={props.canReopen === false ? undefined : handleReopen} reopenDisabledReason={props.reopenDisabledReason} + reopenHint={props.reopenHint} onDelete={() => setDeleteOpen(true)} anchorPoint={menuAnchorPoint} menuId={menuId} diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 96fa6802ae..7af0358e97 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -30,6 +30,7 @@ import { getSessionLastSeenAt, getSessionLastSeenSnapshot } from '@/lib/sessionL import { useSessionRowTooltipIds } from '@/components/HoverTooltip' import { subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' +import { resolveCursorReopenGate } from '@/lib/sessionResume' import { getSessionTitle, hasSessionTitleSignal } from '@/lib/sessionTitle' import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel' import type { Machine } from '@/types/api' @@ -882,17 +883,25 @@ function SessionItem(props: { status: cursorChatStoreStatus, isApplicable: cursorChatStoreApplicable, error: cursorChatStoreError, + isLoading: cursorChatStoreLoading, } = useCursorChatStoreStatus({ api, session: s, enabled: menuOpen }) - const cursorReopenDisabledReason = cursorChatStoreApplicable && cursorChatStoreStatus?.onDisk !== true - ? cursorChatStoreError - ? t('session.action.reopenCursorCheckFailed') - : cursorChatStoreStatus?.onDisk === false - ? t('session.action.reopenCursorMissing') - : t('session.action.reopenCursorChecking') + const cursorReopenGate = resolveCursorReopenGate({ + applicable: cursorChatStoreApplicable, + onDisk: cursorChatStoreStatus?.onDisk, + error: cursorChatStoreError, + isLoading: cursorChatStoreLoading, + }) + const cursorReopenDisabledReason = cursorReopenGate.disabledReason === 'missing' + ? t('session.action.reopenCursorMissing') + : cursorReopenGate.disabledReason === 'checking' + ? t('session.action.reopenCursorChecking') + : undefined + const cursorReopenUnverifiedHint = cursorReopenGate.probeUnverified + ? t('session.action.reopenCursorUnverified') : undefined const { archiveSession, reopenSession, renameSession, deleteSession, setPinMode, isPending } = useSessionActions( @@ -1000,6 +1009,7 @@ function SessionItem(props: { onArchive={() => setArchiveOpen(true)} onReopen={cursorReopenDisabledReason ? undefined : handleReopen} reopenDisabledReason={cursorReopenDisabledReason} + reopenHint={cursorReopenUnverifiedHint} onDelete={() => setDeleteOpen(true)} anchorPoint={menuAnchorPoint} /> diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7f7117191c..debf8735cd 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -242,6 +242,25 @@ export default { 'session.action.reopenCursorChecking': 'Checking whether Cursor chat data is still available on the recorded machine.', 'session.action.reopenCursorMissing': 'Cursor chat data is no longer available on the recorded machine.', 'session.action.reopenCursorCheckFailed': 'Could not verify Cursor chat data on the recorded machine.', + 'session.action.reopenCursorUnverified': 'Could not verify Cursor chat data (runner may be outdated). Reopen is still available — upgrade the CLI on that machine if reopen fails.', + 'runner.skew.updateRequired': 'UPDATE REQUIRED', + 'runner.skew.banner.title': 'Runner out of date on {host}', + 'runner.skew.banner.body': 'This hub needs a newer HAPI CLI on {host} (running {version}). Upgrade the CLI on that machine, then restart the runner (`systemctl --user restart hapi-runner` or `hapi runner start`). This banner stays until the runner reports the required capabilities.', + 'runner.skew.banner.bodyUnknownVersion': 'This hub needs a newer HAPI CLI on {host}. Upgrade the CLI on that machine, then restart the runner (`systemctl --user restart hapi-runner` or `hapi runner start`). This banner stays until the runner reports the required capabilities.', + 'runner.skew.banner.summaryTitle': '{count} runner(s) out of date', + 'runner.skew.banner.summaryBody': 'Upgrade the HAPI CLI on each host below, then restart that runner. Minimize or snooze this banner to reach sessions underneath.', + 'runner.skew.banner.minimized': '{count} outdated runner(s): {hosts} — tap to expand', + 'runner.skew.banner.minimize': 'Minimize', + 'runner.skew.banner.dismissTemp': 'Snooze 1h', + 'runner.skew.banner.restart': 'Restart runner', + 'runner.skew.banner.restartUnavailable': 'Upgrade CLI first', + 'runner.skew.banner.restartNeedsNewerBinary': 'Restart only helps after a newer HAPI CLI is installed on that host. It does not download packages.', + 'runner.skew.banner.restartNeedsSupervisor': 'Restart only works when the runner is supervised (HAPI_RUNNER_SUPERVISED=1 / systemd). Unsupervised stop would leave the host offline.', + 'runner.skew.banner.restarting': 'Restarting…', + 'runner.skew.banner.binaryUpdatedHint': '(newer CLI on disk — runner should self-restart soon)', + 'runner.skew.banner.upgradeCliFirst': '(no newer CLI on disk yet — install/upgrade on that host)', + 'runner.skew.banner.handoffHint': 'After you upgrade the CLI on a host, that runner normally self-restarts (version handoff). Restart here only stops a supervised runner so systemd/pm2 relaunches it — never on a detached laptop runner.', + 'runner.skew.restartFailed': 'Failed to restart runner', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', 'session.action.copyReference': 'Copy reference', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 0b95581b11..cfb6b026ec 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -242,6 +242,25 @@ export default { 'session.action.reopenCursorChecking': '正在检查记录设备上的 Cursor 聊天数据是否仍然可用。', 'session.action.reopenCursorMissing': '记录设备上的 Cursor 聊天数据已不可用。', 'session.action.reopenCursorCheckFailed': '无法验证记录设备上的 Cursor 聊天数据。', + 'session.action.reopenCursorUnverified': '无法验证 Cursor 聊天数据(运行器可能过旧)。仍可尝试重新打开——若失败,请在该机器上升级 CLI。', + 'runner.skew.updateRequired': '需要更新', + 'runner.skew.banner.title': '{host} 上的运行器版本过旧', + 'runner.skew.banner.body': '此 Hub 需要 {host} 上更新的 HAPI CLI(当前 {version})。请在该机器上升级 CLI,然后重启运行器(`systemctl --user restart hapi-runner` 或 `hapi runner start`)。在运行器报告所需能力之前,此横幅会一直显示。', + 'runner.skew.banner.bodyUnknownVersion': '此 Hub 需要 {host} 上更新的 HAPI CLI。请在该机器上升级 CLI,然后重启运行器(`systemctl --user restart hapi-runner` 或 `hapi runner start`)。在运行器报告所需能力之前,此横幅会一直显示。', + 'runner.skew.banner.summaryTitle': '{count} 个运行器版本过旧', + 'runner.skew.banner.summaryBody': '请在下列每台主机上升级 HAPI CLI 并重启运行器。可最小化或 snooze 本横幅以操作下方会话。', + 'runner.skew.banner.minimized': '{count} 个过旧运行器:{hosts} — 点按展开', + 'runner.skew.banner.minimize': '最小化', + 'runner.skew.banner.dismissTemp': '暂隐 1 小时', + 'runner.skew.banner.restart': '重启运行器', + 'runner.skew.banner.restartUnavailable': '请先升级 CLI', + 'runner.skew.banner.restartNeedsNewerBinary': '仅当该主机已安装更新的 HAPI CLI 时,重启才有意义。不会远程下载或安装软件包。', + 'runner.skew.banner.restartNeedsSupervisor': '仅在受监督运行器上可用(HAPI_RUNNER_SUPERVISED=1 / systemd)。无监督 stop 会让主机离线。', + 'runner.skew.banner.restarting': '正在重启…', + 'runner.skew.banner.binaryUpdatedHint': '(磁盘上已有更新 CLI — 运行器应很快自行重启)', + 'runner.skew.banner.upgradeCliFirst': '(磁盘尚无更新 CLI — 请先在该主机安装/升级)', + 'runner.skew.banner.handoffHint': '在主机上升级 CLI 后,运行器通常会自行重启(version handoff)。此处“重启”仅用于受监督运行器(systemd/pm2 会重新拉起),切勿在无监督笔记本运行器上使用。', + 'runner.skew.restartFailed': '重启运行器失败', 'session.action.delete': '删除', 'session.action.copy': '复制', 'session.action.copyReference': '复制引用', diff --git a/web/src/lib/runnerSkewBannerState.test.ts b/web/src/lib/runnerSkewBannerState.test.ts new file mode 100644 index 0000000000..b9c1153e5d --- /dev/null +++ b/web/src/lib/runnerSkewBannerState.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearRunnerSkewTempDismiss, + isRunnerSkewMinimized, + isRunnerSkewTempDismissed, + resetRunnerSkewBannerMemoryForTests, + setRunnerSkewMinimized, + tempDismissRunnerSkew, +} from './runnerSkewBannerState' + +describe('runnerSkewBannerState', () => { + beforeEach(() => { + window.sessionStorage.clear() + resetRunnerSkewBannerMemoryForTests() + }) + + afterEach(() => { + vi.restoreAllMocks() + window.sessionStorage.clear() + resetRunnerSkewBannerMemoryForTests() + }) + + it('persists minimize to sessionStorage', () => { + setRunnerSkewMinimized(true) + expect(isRunnerSkewMinimized()).toBe(true) + expect(window.sessionStorage.getItem('hapi.runnerSkew.minimized.v1')).toBe('1') + }) + + it('still minimizes when sessionStorage setItem throws QuotaExceededError', () => { + const proto = Object.getPrototypeOf(window.sessionStorage) as Storage + vi.spyOn(proto, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError') + }) + + expect(() => setRunnerSkewMinimized(true)).not.toThrow() + expect(isRunnerSkewMinimized()).toBe(true) + }) + + it('still temp-dismisses when sessionStorage is full', () => { + const proto = Object.getPrototypeOf(window.sessionStorage) as Storage + vi.spyOn(proto, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError') + }) + + const now = 1_700_000_000_000 + expect(() => tempDismissRunnerSkew(now)).not.toThrow() + expect(isRunnerSkewTempDismissed(now + 1)).toBe(true) + clearRunnerSkewTempDismiss() + expect(isRunnerSkewTempDismissed(now + 1)).toBe(false) + }) +}) diff --git a/web/src/lib/runnerSkewBannerState.ts b/web/src/lib/runnerSkewBannerState.ts new file mode 100644 index 0000000000..08c2225d87 --- /dev/null +++ b/web/src/lib/runnerSkewBannerState.ts @@ -0,0 +1,93 @@ +const MINIMIZED_KEY = 'hapi.runnerSkew.minimized.v1' +const DISMISS_UNTIL_KEY = 'hapi.runnerSkew.dismissUntil.v1' +export const RUNNER_SKEW_TEMP_DISMISS_MS = 60 * 60_000 + +/** In-memory fallback when sessionStorage is full / blocked (QuotaExceededError). */ +let memoryMinimized: boolean | null = null +let memoryDismissUntil: number | null = null + +function readStorage(): Storage | null { + if (typeof window === 'undefined') { + return null + } + try { + return window.sessionStorage + } catch { + return null + } +} + +function writeStorage(mutate: (storage: Storage) => void): void { + const storage = readStorage() + if (!storage) { + return + } + try { + mutate(storage) + } catch { + // QuotaExceededError / SecurityError — keep memory fallback only. + } +} + +export function isRunnerSkewMinimized(): boolean { + if (memoryMinimized !== null) { + return memoryMinimized + } + try { + return readStorage()?.getItem(MINIMIZED_KEY) === '1' + } catch { + return false + } +} + +export function setRunnerSkewMinimized(minimized: boolean): void { + memoryMinimized = minimized + writeStorage((storage) => { + if (minimized) { + storage.setItem(MINIMIZED_KEY, '1') + } else { + storage.removeItem(MINIMIZED_KEY) + } + }) +} + +export function getRunnerSkewDismissUntil(): number { + if (memoryDismissUntil !== null) { + return memoryDismissUntil + } + try { + const raw = readStorage()?.getItem(DISMISS_UNTIL_KEY) + if (!raw) { + return 0 + } + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : 0 + } catch { + return 0 + } +} + +export function isRunnerSkewTempDismissed(now: number = Date.now()): boolean { + return getRunnerSkewDismissUntil() > now +} + +export function tempDismissRunnerSkew(now: number = Date.now()): void { + const until = now + RUNNER_SKEW_TEMP_DISMISS_MS + memoryDismissUntil = until + writeStorage((storage) => { + storage.setItem(DISMISS_UNTIL_KEY, String(until)) + }) +} + +export function clearRunnerSkewTempDismiss(): void { + memoryDismissUntil = 0 + writeStorage((storage) => { + storage.removeItem(DISMISS_UNTIL_KEY) + }) +} + +/** Test helper: reset memory mirrors (sessionStorage cleared separately). */ +export function resetRunnerSkewBannerMemoryForTests(): void { + memoryMinimized = null + memoryDismissUntil = null +} diff --git a/web/src/lib/sessionResume.test.ts b/web/src/lib/sessionResume.test.ts index 08eb68c733..2d2f65ae34 100644 --- a/web/src/lib/sessionResume.test.ts +++ b/web/src/lib/sessionResume.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { Session } from '@/types/api' -import { inactiveSessionCanResume, resolveAgentSessionIdFromMetadata } from './sessionResume' +import { + inactiveSessionCanResume, + resolveAgentSessionIdFromMetadata, + resolveCursorReopenGate, +} from './sessionResume' function makeSession(overrides: Partial = {}): Session { return { @@ -67,7 +71,7 @@ describe('sessionResume', () => { }), 5, true)).toBe(true) }) - it('conservatively rejects cursor resume until the chat store is verified', () => { + it('allows cursor resume when chat-store probe is unverified (skew / missing handler)', () => { expect(inactiveSessionCanResume(makeSession({ metadata: { path: '/tmp/project', @@ -75,7 +79,7 @@ describe('sessionResume', () => { flavor: 'cursor', cursorSessionId: 'cursor-thread-1', }, - }), 5)).toBe(false) + }), 5)).toBe(true) }) it('rejects cursor resume when the recorded chat store is missing on its machine', () => { @@ -89,6 +93,33 @@ describe('sessionResume', () => { }), 5, false)).toBe(false) }) + it('resolveCursorReopenGate only disables for definitive onDisk:false', () => { + expect(resolveCursorReopenGate({ + applicable: true, + onDisk: false, + error: null, + isLoading: false, + })).toEqual({ disabledReason: 'missing', probeUnverified: false }) + expect(resolveCursorReopenGate({ + applicable: true, + onDisk: undefined, + error: 'RPC handler not registered', + isLoading: false, + })).toEqual({ disabledReason: null, probeUnverified: true }) + expect(resolveCursorReopenGate({ + applicable: true, + onDisk: true, + error: null, + isLoading: false, + })).toEqual({ disabledReason: null, probeUnverified: false }) + expect(resolveCursorReopenGate({ + applicable: true, + onDisk: undefined, + error: null, + isLoading: true, + })).toEqual({ disabledReason: 'checking', probeUnverified: false }) + }) + it('does not apply Cursor chat store status to other agent flavors', () => { expect(inactiveSessionCanResume(makeSession({ metadata: { diff --git a/web/src/lib/sessionResume.ts b/web/src/lib/sessionResume.ts index a79e95ca05..77ed5c776e 100644 --- a/web/src/lib/sessionResume.ts +++ b/web/src/lib/sessionResume.ts @@ -32,6 +32,10 @@ export function resolveAgentSessionIdFromMetadata( * Claude and Codex with messages but no flavor-specific id may attempt the * hub-authoritative stored-message recovery path; the hub still rejects logs * without a safe resume id. + * + * Cursor: definitive `onDisk: false` still blocks. Probe failure / unknown + * (`undefined`) must NOT be treated as missing data — allow reopen and show + * honest messaging (#1084). */ export function inactiveSessionCanResume( session: Session, @@ -47,7 +51,7 @@ export function inactiveSessionCanResume( if (resolveAgentSessionIdFromMetadata(session.metadata)) { const flavor = isKnownFlavor(session.metadata.flavor) ? session.metadata.flavor : 'claude' if (flavor === 'cursor') { - return cursorChatOnDisk === true + return cursorChatOnDisk !== false } return true } @@ -57,3 +61,34 @@ export function inactiveSessionCanResume( } return userMessageCount === 0 } + +export type CursorReopenGateReason = 'missing' | 'checking' + +/** + * UI gate for Cursor reopen. Only definitive `onDisk: false` disables reopen. + * Probe errors / unknown status allow the attempt (soft-fail) with optional + * unverified messaging. + */ +export function resolveCursorReopenGate(args: { + applicable: boolean + onDisk: boolean | undefined + error: string | null + isLoading: boolean +}): { disabledReason: CursorReopenGateReason | null; probeUnverified: boolean } { + if (!args.applicable) { + return { disabledReason: null, probeUnverified: false } + } + if (args.onDisk === false) { + return { disabledReason: 'missing', probeUnverified: false } + } + if (args.onDisk === true) { + return { disabledReason: null, probeUnverified: false } + } + if (args.error) { + return { disabledReason: null, probeUnverified: true } + } + if (args.isLoading) { + return { disabledReason: 'checking', probeUnverified: false } + } + return { disabledReason: 'checking', probeUnverified: false } +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 6b796b64e0..b974229635 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -46,7 +46,7 @@ import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend' import { transferComposerDraftThenNavigate } from '@/lib/composer-draft-transfer' import { getDraftAttachments } from '@/lib/composer-attachment-drafts' import { refreshSessionDetailPreservingActive } from '@/lib/session-detail-optimistic' -import { inactiveSessionCanResume } from '@/lib/sessionResume' +import { inactiveSessionCanResume, resolveCursorReopenGate } from '@/lib/sessionResume' import { initializeSessionLastSeen, markSessionSeen } from '@/lib/sessionLastSeen' import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle' import { clearCodexImportedSession } from '@/lib/codexImportedSessions' @@ -346,6 +346,7 @@ function SessionPage() { status: cursorChatStoreStatus, isApplicable: cursorChatStoreApplicable, error: cursorChatStoreError, + isLoading: cursorChatStoreLoading, } = useCursorChatStoreStatus({ api, session }) const { messages, @@ -460,12 +461,19 @@ function SessionPage() { })() }, [api, queryClient, navigate, addToast, t]) - const cursorReopenDisabledReason = cursorChatStoreApplicable && cursorChatStoreStatus?.onDisk !== true - ? cursorChatStoreError - ? t('session.action.reopenCursorCheckFailed') - : cursorChatStoreStatus?.onDisk === false - ? t('session.action.reopenCursorMissing') - : t('session.action.reopenCursorChecking') + const cursorReopenGate = resolveCursorReopenGate({ + applicable: cursorChatStoreApplicable, + onDisk: cursorChatStoreStatus?.onDisk, + error: cursorChatStoreError, + isLoading: cursorChatStoreLoading, + }) + const cursorReopenDisabledReason = cursorReopenGate.disabledReason === 'missing' + ? t('session.action.reopenCursorMissing') + : cursorReopenGate.disabledReason === 'checking' + ? t('session.action.reopenCursorChecking') + : undefined + const cursorReopenUnverifiedHint = cursorReopenGate.probeUnverified + ? t('session.action.reopenCursorUnverified') : undefined const canOfferInactiveReopen = session ? inactiveSessionCanResume(session, messages.length, cursorChatStoreStatus?.onDisk) @@ -768,6 +776,7 @@ function SessionPage() { session={session} cursorChatOnDisk={cursorChatStoreStatus?.onDisk} reopenDisabledReason={cursorReopenDisabledReason} + reopenHint={cursorReopenUnverifiedHint} messages={messages} messagesWarning={messagesWarning} hasMoreMessages={messagesHasMore}
+ {t('runner.skew.banner.summaryTitle', { count: skewed.length })} +
+ {t('runner.skew.banner.summaryBody')} +
+ {restartError} +
+ {t('runner.skew.banner.handoffHint')} +