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) => ( ))} 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 ( +
+ +
+ ) + } + + return ( +
+
+
+

+ {t('runner.skew.banner.summaryTitle', { count: skewed.length })} +

+

+ {t('runner.skew.banner.summaryBody')} +

+
+
+ + +
+
+ +
    + {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')} + + )} +
    + +
    +
  • + ) + })} +
+ + {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}