diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 50e558f2..ecfabd7e 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -512,6 +512,134 @@ export class SessionCache { this.refreshSession(sessionId) } + /** + * Clear archive-related metadata on an archived session so it can be resumed. + * - Removes `lifecycleState`, `archivedBy`, `archiveReason`, and stamps + * `lifecycleStateSince` so subsequent CLI lifecycle writes still win on time. + * - For Cursor sessions that pre-date #799 (no `cursorSessionProtocol` set, but a + * `cursorSessionId` exists) defaults the protocol to `stream-json` so routing + * reaches the legacy launcher instead of the new ACP path. + * + * Returns the protocol that was applied (or already present) for cursor sessions, + * or `undefined` for other flavors. Throws on version mismatch / store error. + * No-op when metadata is null (callers should pre-check). + */ + async clearSessionArchiveMetadata(sessionId: string): Promise<{ cursorSessionProtocol?: 'acp' | 'stream-json' }> { + const session = this.sessions.get(sessionId) + if (!session) { + throw new Error('Session not found') + } + + const currentMetadata = session.metadata + if (!currentMetadata) { + throw new Error('Session metadata missing') + } + + const next: Record = { ...currentMetadata } + delete next.lifecycleState + delete next.archivedBy + delete next.archiveReason + next.lifecycleStateSince = Date.now() + + let cursorSessionProtocol: 'acp' | 'stream-json' | undefined + if (currentMetadata.flavor === 'cursor') { + const existing = currentMetadata.cursorSessionProtocol + if (existing === 'acp' || existing === 'stream-json') { + cursorSessionProtocol = existing + } else if (currentMetadata.cursorSessionId) { + // Pre-#799 default: presence of cursorSessionId without protocol means stream-json. + cursorSessionProtocol = 'stream-json' + next.cursorSessionProtocol = 'stream-json' + } + } + + const result = this.store.sessions.updateSessionMetadata( + sessionId, + next, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + throw new Error('Failed to update session metadata') + } + + if (result.result === 'version-mismatch') { + throw new Error('Session was modified concurrently. Please try again.') + } + + this.refreshSession(sessionId) + return cursorSessionProtocol ? { cursorSessionProtocol } : {} + } + + /** + * Restore archive-related metadata fields that were captured before a reopen attempt. + * Used when `resumeSession` fails after `clearSessionArchiveMetadata` already ran so the + * session does not drift into a "not archived, not active" zombie state. + * + * Restores the four archive fields **exactly**: if a field was present in the snapshot + * it is written, if it was absent it is deleted (covering the case where + * `clearSessionArchiveMetadata` stamped a fresh `lifecycleStateSince` on a row that did + * not have one originally). Other concurrent edits (e.g. a rename in flight) are + * preserved. Returns silently if the session is gone or its metadata is unset; throws + * on version mismatch so the caller can decide whether to retry. + */ + async restoreSessionArchiveMetadata( + sessionId: string, + snapshot: { + lifecycleState?: string + archivedBy?: string + archiveReason?: string + lifecycleStateSince?: number + } + ): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + const current = session.metadata + if (!current) return + + const next: Record = { ...current } + if (snapshot.lifecycleState !== undefined) { + next.lifecycleState = snapshot.lifecycleState + } else { + delete next.lifecycleState + } + if (snapshot.archivedBy !== undefined) { + next.archivedBy = snapshot.archivedBy + } else { + delete next.archivedBy + } + if (snapshot.archiveReason !== undefined) { + next.archiveReason = snapshot.archiveReason + } else { + delete next.archiveReason + } + if (snapshot.lifecycleStateSince !== undefined) { + next.lifecycleStateSince = snapshot.lifecycleStateSince + } else { + delete next.lifecycleStateSince + } + + const result = this.store.sessions.updateSessionMetadata( + sessionId, + next, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + throw new Error('Failed to restore archive metadata') + } + + if (result.result === 'version-mismatch') { + throw new Error('Session was modified concurrently during reopen rollback') + } + + this.refreshSession(sessionId) + } + async deleteSession(sessionId: string): Promise { const session = this.sessions.get(sessionId) if (!session) { diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 6cb45ebc..4ec57f5a 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1806,4 +1806,313 @@ describe('session model', () => { expect(state.completedRequests?.['req-1']).toBeDefined() }) }) + + describe('clearSessionArchiveMetadata', () => { + it('clears lifecycleState/archivedBy/archiveReason from an archived session', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-archived', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'thread-X', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + }, + null, + 'default' + ) + + const result = await cache.clearSessionArchiveMetadata(session.id) + + expect(result.cursorSessionProtocol).toBeUndefined() + const updated = cache.getSession(session.id) + const meta = updated?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBeUndefined() + expect(meta?.archivedBy).toBeUndefined() + expect(meta?.archiveReason).toBeUndefined() + expect(typeof meta?.lifecycleStateSince).toBe('number') + }) + + it('defaults cursorSessionProtocol to stream-json for pre-#799 cursor sessions', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-cursor-legacy', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor', + cursorSessionId: 'legacy-cursor-id', + lifecycleState: 'archived' + }, + null, + 'default' + ) + + const result = await cache.clearSessionArchiveMetadata(session.id) + + expect(result.cursorSessionProtocol).toBe('stream-json') + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.cursorSessionProtocol).toBe('stream-json') + }) + + it('keeps an existing acp protocol intact when clearing archive metadata', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-cursor-acp', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor', + cursorSessionId: 'acp-cursor-id', + cursorSessionProtocol: 'acp', + lifecycleState: 'archived' + }, + null, + 'default' + ) + + const result = await cache.clearSessionArchiveMetadata(session.id) + + expect(result.cursorSessionProtocol).toBe('acp') + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.cursorSessionProtocol).toBe('acp') + expect(meta?.lifecycleState).toBeUndefined() + }) + + it('does not stamp cursorSessionProtocol when no cursorSessionId is present', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-cursor-fresh', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor', + lifecycleState: 'archived' + }, + null, + 'default' + ) + + const result = await cache.clearSessionArchiveMetadata(session.id) + + expect(result.cursorSessionProtocol).toBeUndefined() + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.cursorSessionProtocol).toBeUndefined() + }) + + it('throws when the session id is unknown', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + await expect(cache.clearSessionArchiveMetadata('missing-session')).rejects.toThrow('Session not found') + }) + }) + + describe('reopenSession rollback', () => { + it('restores archive metadata when resumeSession fails after the clear', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-reopen-rollback', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'Session crashed', + lifecycleStateSince: 1000 + }, + null, + 'default' + ) + // No machine registered -> resumeSession returns no_machine_online. + + const result = await engine.reopenSession(session.id, 'default') + + expect(result.type).toBe('error') + if (result.type === 'error') { + expect(result.code).toBe('no_machine_online') + } + + const restored = engine.getSessionByNamespace(session.id, 'default')?.metadata as Record | null | undefined + expect(restored?.lifecycleState).toBe('archived') + expect(restored?.archivedBy).toBe('cli') + expect(restored?.archiveReason).toBe('Session crashed') + } finally { + engine.stop() + } + }) + + it('does not roll back when resumeSession succeeds', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-reopen-success', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-2', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + ;(engine as any).rpcGateway.spawnSession = async () => ({ type: 'success', sessionId: session.id }) + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.reopenSession(session.id, 'default') + + expect(result.type).toBe('success') + if (result.type === 'success') { + expect(result.resumed).toBe(true) + } + + const after = engine.getSessionByNamespace(session.id, 'default')?.metadata as Record | null | undefined + expect(after?.lifecycleState).toBeUndefined() + expect(after?.archivedBy).toBeUndefined() + expect(after?.archiveReason).toBeUndefined() + } finally { + engine.stop() + } + }) + }) + + describe('restoreSessionArchiveMetadata', () => { + it('puts back lifecycleState/archivedBy/archiveReason from a snapshot', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-restore', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'thread-Y', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated', + lifecycleStateSince: 1234567890 + }, + null, + 'default' + ) + + await cache.clearSessionArchiveMetadata(session.id) + const cleared = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(cleared?.lifecycleState).toBeUndefined() + + await cache.restoreSessionArchiveMetadata(session.id, { + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated', + lifecycleStateSince: 1234567890 + }) + + const restored = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(restored?.lifecycleState).toBe('archived') + expect(restored?.archivedBy).toBe('cli') + expect(restored?.archiveReason).toBe('User terminated') + expect(restored?.lifecycleStateSince).toBe(1234567890) + }) + + it('deletes archive fields that were absent in the snapshot for an exact restore', async () => { + // Covers the legacy case: an archived session that predates `lifecycleStateSince`. + // `clearSessionArchiveMetadata` stamps a fresh `lifecycleStateSince`; if reopen + // then fails, the restore must drop that stamp so the row's lifecycle age does + // not appear to be "just now" to UI / import code. + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-restore-partial', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'thread-Z', + lifecycleState: 'archived', + archiveReason: 'Session crashed' + // no archivedBy, no lifecycleStateSince + }, + null, + 'default' + ) + + await cache.clearSessionArchiveMetadata(session.id) + // lifecycleStateSince was just stamped fresh by the clear; verify it's set so + // the next assertion proves the restore actively deleted it. + const cleared = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(typeof cleared?.lifecycleStateSince).toBe('number') + + await cache.restoreSessionArchiveMetadata(session.id, { + lifecycleState: 'archived', + archiveReason: 'Session crashed' + // archivedBy + lifecycleStateSince intentionally absent from snapshot + }) + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBe('archived') + expect(meta?.archiveReason).toBe('Session crashed') + expect(meta?.archivedBy).toBeUndefined() + expect(meta?.lifecycleStateSince).toBeUndefined() + }) + + it('is a no-op when the session is gone', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + await expect(cache.restoreSessionArchiveMetadata('missing', { + lifecycleState: 'archived' + })).resolves.toBeUndefined() + }) + }) }) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index cd08717c..a6157821 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -60,6 +60,11 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } +export type ReopenSessionResult = + | { type: 'success'; sessionId: string; resumed: boolean; cursorSessionProtocol?: 'acp' | 'stream-json' } + | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' | 'metadata_conflict' } + | { type: 'incomplete'; message: string; missing: [string, ...string[]] } + export type LocalResumeTargetResult = | { type: 'success'; target: LocalResumeTarget } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'resume_unavailable' } @@ -744,6 +749,106 @@ export class SyncEngine { return { type: 'success', sessionId: spawnResult.sessionId } } + /** + * Revive an archived session so the web UI can reach it again. + * + * Behaviour: + * - Active session: idempotent no-op (`resumed: false`). + * - Non-archived inactive session: forwards to `resumeSession` without touching metadata. + * - Archived session: validates that the agent has enough metadata to resume (Cursor + * sessions require a `cursorSessionId` once they have any messages), clears the + * archive metadata (`lifecycleState`, `archivedBy`, `archiveReason`), defaults the + * Cursor protocol to `stream-json` for pre-#799 sessions, then forwards to + * `resumeSession`. The CLI's `sessionFactory` will re-stamp `lifecycleState='running'` + * when it boots, so we do not pre-write that here. + * + * Failure rollback: if `resumeSession` fails (no machine online, spawn timeout, etc.) + * the archive snapshot is restored so the operator can retry without losing + * `archiveReason`/`archivedBy`/`lifecycleState` and the UI still shows the row as + * archived rather than a dangling inactive non-archived ghost. + * + * Returns `incomplete` (HTTP 422 from the route layer) when the agent metadata + * needed to resume is missing. + */ + async reopenSession(sessionId: string, namespace: string): Promise { + const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) + if (!access.ok) { + return { + type: 'error', + message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', + code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' + } + } + + const session = access.session + const metadata = session.metadata + + if (session.active) { + return { type: 'success', sessionId: access.sessionId, resumed: false } + } + + const isArchived = metadata?.lifecycleState === 'archived' + + if (isArchived && metadata) { + if (metadata.flavor === 'cursor' && !metadata.cursorSessionId) { + const hasMessages = this.store.messages.getFirstMessages(access.sessionId, 1).length > 0 + if (hasMessages) { + return { + type: 'incomplete', + message: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', + missing: ['cursorSessionId'] + } + } + } + + const archiveSnapshot = { + lifecycleState: metadata.lifecycleState, + archivedBy: metadata.archivedBy, + archiveReason: metadata.archiveReason, + lifecycleStateSince: metadata.lifecycleStateSince + } + + let applied: { cursorSessionProtocol?: 'acp' | 'stream-json' } + try { + applied = await this.sessionCache.clearSessionArchiveMetadata(access.sessionId) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to clear archive metadata' + return { type: 'error', message, code: 'metadata_conflict' } + } + + const resumeResult = await this.resumeSession(access.sessionId, namespace) + if (resumeResult.type === 'error') { + // Resume failed - put the archive flags back so the row stays archived in the UI + // and the operator can retry. Best-effort: a concurrent metadata write that + // succeeded between clear and restore (e.g. an unrelated rename) wins, in + // which case we surface the original resume error rather than masking it. + try { + await this.sessionCache.restoreSessionArchiveMetadata(access.sessionId, archiveSnapshot) + } catch { + // Swallow restore failures - the resume error is the more important signal. + } + return resumeResult + } + + return { + type: 'success', + sessionId: resumeResult.sessionId, + resumed: true, + ...(applied.cursorSessionProtocol ? { cursorSessionProtocol: applied.cursorSessionProtocol } : {}) + } + } + + // Not active and not archived (e.g. brand-new session that has not yet connected, + // or one that ended without writing archive metadata). Forward to resume so the + // operator still gets one-click revival. + const resumeResult = await this.resumeSession(access.sessionId, namespace) + if (resumeResult.type === 'error') { + return resumeResult + } + + return { type: 'success', sessionId: resumeResult.sessionId, resumed: true } + } + async handoffSessionToLocal(sessionId: string, namespace: string): Promise { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 68e1f9f1..604ef867 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -50,10 +50,17 @@ function createSession(overrides?: Partial): Session { } } +type ReopenResultMock = + | { type: 'success'; sessionId: string; resumed: boolean; cursorSessionProtocol?: 'acp' | 'stream-json' } + | { type: 'error'; message: string; code: string } + | { type: 'incomplete'; message: string; missing: [string, ...string[]] } + function createApp(session: Session, opts?: { resumeSession?: (sessionId: string, namespace: string, resumeOpts?: { permissionMode?: string }) => Promise<{ type: string; sessionId?: string; message?: string; code?: string }> + reopenSession?: (sessionId: string, namespace: string) => Promise listSlashCommands?: SyncEngine['listSlashCommands'] getSessionExport?: (sessionId: string, session: Session) => unknown + sessionExists?: boolean }) { const applySessionConfigCalls: Array<[string, Record]> = [] const applySessionConfig = async (sessionId: string, config: Record) => { @@ -82,13 +89,22 @@ function createApp(session: Session, opts?: { currentModelId: 'composer-2.5' }) const resumeSession = opts?.resumeSession ?? (async (sessionId: string) => ({ type: 'success', sessionId })) + const reopenSession = opts?.reopenSession ?? (async (sessionId: string) => ({ + type: 'success' as const, + sessionId, + resumed: true + })) + const sessionExists = opts?.sessionExists !== false const engine = { - resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }), + resolveSessionAccess: () => sessionExists + ? { ok: true, sessionId: session.id, session } + : { ok: false, reason: 'not-found' }, applySessionConfig, listCodexModelsForSession, listCursorModelsForSession, listOpencodeModelsForSession, resumeSession, + reopenSession, getSessionExport: opts?.getSessionExport ?? (() => ({ type: 'success', payload: { @@ -737,6 +753,132 @@ describe('sessions routes', () => { }) }) + it('reopens an archived session and reports resumed=true', async () => { + const session = createSession({ + active: false, + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-1', + cursorSessionProtocol: 'acp', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + } + }) + const reopenCalls: Array<[string, string]> = [] + const { app } = createApp(session, { + reopenSession: async (sessionId, namespace) => { + reopenCalls.push([sessionId, namespace]) + return { type: 'success', sessionId, resumed: true, cursorSessionProtocol: 'acp' } + } + }) + + const response = await app.request('/api/sessions/session-1/reopen', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + sessionId: 'session-1', + resumed: true, + cursorSessionProtocol: 'acp' + }) + expect(reopenCalls).toEqual([['session-1', 'default']]) + }) + + it('reopens a running session as an idempotent no-op (resumed=false)', async () => { + const session = createSession({ active: true }) + const { app } = createApp(session, { + reopenSession: async (sessionId) => ({ type: 'success', sessionId, resumed: false }) + }) + + const response = await app.request('/api/sessions/session-1/reopen', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + sessionId: 'session-1', + resumed: false + }) + }) + + it('returns 422 when a cursor archive is missing cursorSessionId', async () => { + const session = createSession({ + active: false, + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'cursor', + lifecycleState: 'archived' + } + }) + const { app } = createApp(session, { + reopenSession: async () => ({ + type: 'incomplete', + message: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', + missing: ['cursorSessionId'] + }) + }) + + const response = await app.request('/api/sessions/session-1/reopen', { method: 'POST' }) + + expect(response.status).toBe(422) + expect(await response.json()).toEqual({ + error: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', + missing: ['cursorSessionId'] + }) + }) + + it('returns 404 when reopening a non-existent session', async () => { + const session = createSession() + const { app } = createApp(session, { sessionExists: false }) + + const response = await app.request('/api/sessions/missing-id/reopen', { method: 'POST' }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Session not found' }) + }) + + it('maps engine resume_unavailable into a 409', async () => { + const session = createSession({ active: false }) + const { app } = createApp(session, { + reopenSession: async () => ({ + type: 'error', + message: 'Resume session ID unavailable', + code: 'resume_unavailable' + }) + }) + + const response = await app.request('/api/sessions/session-1/reopen', { method: 'POST' }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Resume session ID unavailable', + code: 'resume_unavailable' + }) + }) + + it('maps engine no_machine_online into a 503', async () => { + const session = createSession({ active: false }) + const { app } = createApp(session, { + reopenSession: async () => ({ + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + }) + }) + + const response = await app.request('/api/sessions/session-1/reopen', { method: 'POST' }) + + expect(response.status).toBe(503) + expect((await response.json() as { code: string }).code).toBe('no_machine_online') + }) + it('merges RPC and metadata slash commands without hiding built-ins', async () => { const session = createSession({ metadata: { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 50b6f665..b1b5c003 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -171,6 +171,42 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ type: 'success', sessionId: result.sessionId }) }) + app.post('/sessions/:id/reopen', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: false }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const namespace = c.get('namespace') + const result = await engine.reopenSession(sessionResult.sessionId, namespace) + + if (result.type === 'incomplete') { + return c.json({ error: result.message, missing: result.missing }, 422) + } + + if (result.type === 'error') { + const status = result.code === 'no_machine_online' ? 503 + : result.code === 'access_denied' ? 403 + : result.code === 'session_not_found' ? 404 + : result.code === 'resume_unavailable' ? 409 + : result.code === 'metadata_conflict' ? 409 + : 500 + return c.json({ error: result.message, code: result.code }, status) + } + + return c.json({ + ok: true, + sessionId: result.sessionId, + resumed: result.resumed, + ...(result.cursorSessionProtocol ? { cursorSessionProtocol: result.cursorSessionProtocol } : {}) + }) + }) + app.post('/sessions/:id/upload', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 2762a373..e9ea6a1f 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -101,6 +101,22 @@ export const ResumeSessionRequestSchema = z.object({ export type ResumeSessionRequest = z.infer +export const ReopenSessionResponseSchema = z.object({ + ok: z.literal(true), + sessionId: z.string(), + resumed: z.boolean(), + cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional() +}) + +export type ReopenSessionResponse = z.infer + +export const ReopenSessionMissingMetadataResponseSchema = z.object({ + error: z.string(), + missing: z.array(z.string()).nonempty() +}) + +export type ReopenSessionMissingMetadataResponse = z.infer + export const SessionCollaborationModeRequestSchema = z.object({ mode: CodexCollaborationModeSchema }) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 00000000..343e3336 --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiClient, ApiError } from './client' + +describe('ApiClient error mapping', () => { + let originalFetch: typeof globalThis.fetch + let fetchMock: ReturnType + + beforeEach(() => { + originalFetch = globalThis.fetch + fetchMock = vi.fn() + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('prefers the stable `code` field over the human-readable `error` message in ApiError.code', async () => { + // Match the shape /sessions/:id/reopen actually returns on a 503. + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: 'No machine online', code: 'no_machine_online' }), + { status: 503, statusText: 'Service Unavailable' } + ) + ) + + const api = new ApiClient('test-token') + try { + await api.reopenSession('session-X') + expect.unreachable('expected reopenSession to throw') + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + const apiError = error as ApiError + expect(apiError.status).toBe(503) + // The stable taxonomy must survive into ApiError.code so callers can + // branch on `no_machine_online` rather than parsing the message text. + expect(apiError.code).toBe('no_machine_online') + expect(apiError.body).toContain('no_machine_online') + } + }) + + it('falls back to `parsed.error` when `code` is absent (legacy route shape)', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: 'something broke' }), + { status: 500, statusText: 'Internal Server Error' } + ) + ) + + const api = new ApiClient('test-token') + try { + await api.reopenSession('session-Y') + expect.unreachable('expected reopenSession to throw') + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + expect((error as ApiError).code).toBe('something broke') + } + }) + + it('passes the 422 missing-metadata body through unchanged so the UI can show the missing fields', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', + missing: ['cursorSessionId'] + }), + { status: 422, statusText: 'Unprocessable Entity' } + ) + ) + + const api = new ApiClient('test-token') + try { + await api.reopenSession('session-Z') + expect.unreachable('expected reopenSession to throw') + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + const apiError = error as ApiError + expect(apiError.status).toBe(422) + expect(apiError.body).toContain('cursorSessionId') + } + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a29678a1..2a7bc912 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -33,6 +33,7 @@ import type { MachineListDirectoryResponse, MachinePathsExistsResponse, OpencodeModelsResponse, + ReopenSessionResponse, UploadFileResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor } from '@hapi/protocol' @@ -46,12 +47,15 @@ type ApiClientOptions = { type ErrorPayload = { error?: unknown + code?: unknown } function parseErrorCode(bodyText: string): string | undefined { try { const parsed = JSON.parse(bodyText) as ErrorPayload - return typeof parsed.error === 'string' ? parsed.error : undefined + if (typeof parsed.code === 'string') return parsed.code + if (typeof parsed.error === 'string') return parsed.error + return undefined } catch { return undefined } @@ -131,7 +135,13 @@ export class ApiClient { if (!res.ok) { const body = await res.text().catch(() => '') - throw new Error(`HTTP ${res.status} ${res.statusText}: ${body}`) + const code = parseErrorCode(body) + throw new ApiError( + `HTTP ${res.status} ${res.statusText}: ${body}`, + res.status, + code, + body || undefined + ) } return await res.json() as T @@ -408,6 +418,13 @@ export class ApiClient { }) } + async reopenSession(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/reopen`, + { method: 'POST', body: JSON.stringify({}) } + ) + } + async switchSession(sessionId: string): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/switch`, { method: 'POST', diff --git a/web/src/components/SessionActionMenu.test.tsx b/web/src/components/SessionActionMenu.test.tsx new file mode 100644 index 00000000..ec53b0f1 --- /dev/null +++ b/web/src/components/SessionActionMenu.test.tsx @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { SessionActionMenu } from '@/components/SessionActionMenu' + +afterEach(() => cleanup()) + +function renderMenu(overrides: Partial> = {}) { + const defaults: React.ComponentProps = { + isOpen: true, + onClose: vi.fn(), + sessionActive: false, + onRename: vi.fn(), + onArchive: vi.fn(), + onReopen: vi.fn(), + onDelete: vi.fn(), + anchorPoint: { x: 0, y: 0 }, + } + const merged = { ...defaults, ...overrides } + return { + ...render( + + + + ), + props: merged + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('SessionActionMenu - Reopen action', () => { + it('renders the Reopen item on inactive sessions when onReopen is provided', () => { + renderMenu({ sessionActive: false }) + + expect(screen.getByRole('menuitem', { name: /Reopen/ })).toBeInTheDocument() + }) + + it('does not render the Reopen item on active sessions', () => { + renderMenu({ sessionActive: true }) + + expect(screen.queryByRole('menuitem', { name: /Reopen/ })).toBeNull() + }) + + it('does not render the Reopen item when onReopen is omitted (back-compat)', () => { + renderMenu({ sessionActive: false, onReopen: undefined }) + + expect(screen.queryByRole('menuitem', { name: /Reopen/ })).toBeNull() + // Delete item is still present for inactive sessions. + expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeInTheDocument() + }) + + it('fires onReopen and closes the menu when the Reopen item is clicked', () => { + const onReopen = vi.fn() + const onClose = vi.fn() + renderMenu({ sessionActive: false, onReopen, onClose }) + + fireEvent.click(screen.getByRole('menuitem', { name: /Reopen/ })) + + expect(onReopen).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('renders Reopen alongside Delete for inactive sessions', () => { + renderMenu({ sessionActive: false }) + + expect(screen.getByRole('menuitem', { name: /Reopen/ })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeInTheDocument() + // Archive should not show up for inactive sessions (it is the active-session destructive). + expect(screen.queryByRole('menuitem', { name: /Archive/ })).toBeNull() + }) +}) diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index 60068b9d..9a958b40 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -16,6 +16,7 @@ type SessionActionMenuProps = { onRename: () => void onExport?: () => void onArchive: () => void + onReopen?: () => void onDelete: () => void anchorPoint: { x: number; y: number } menuId?: string @@ -83,6 +84,26 @@ function DownloadIcon(props: { className?: string }) { ) } +function ReopenIcon(props: { className?: string }) { + return ( + + + + + ) +} + function TrashIcon(props: { className?: string }) { return ( { + onClose() + onReopen?.() + } + const handleExport = () => { onClose() onExport?.() @@ -290,15 +317,28 @@ export function SessionActionMenu(props: SessionActionMenuProps) { {t('session.action.archive')} ) : ( - + <> + {onReopen ? ( + + ) : null} + + )} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 12c86dbf..254dfcfe 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -709,6 +709,13 @@ export function SessionChat(props: { onOpenOutline={() => setOutlineOpen(true)} api={props.api} onSessionDeleted={props.onBack} + onSessionReopened={(newSessionId) => { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId: newSessionId }, + replace: true + }) + }} /> {props.session.teamState && ( diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 407b23d4..a65f84d0 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -7,6 +7,7 @@ import { SessionActionMenu } from '@/components/SessionActionMenu' import { SessionExportDialog } from '@/components/SessionExportDialog' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { formatReopenError } from '@/lib/reopenError' import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' @@ -93,9 +94,10 @@ export function SessionHeader(props: { onOpenOutline?: () => void api: ApiClient | null onSessionDeleted?: () => void + onSessionReopened?: (newSessionId: string) => void }) { const { t } = useTranslation() - const { session, api, onSessionDeleted } = props + const { session, api, onSessionDeleted, onSessionReopened } = props const title = useMemo(() => getSessionTitle(session), [session]) const worktreeBranch = session.metadata?.worktree?.branch const modelLabel = getSessionModelLabel(session) @@ -109,17 +111,30 @@ export function SessionHeader(props: { const [archiveOpen, setArchiveOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) - const { archiveSession, renameSession, deleteSession, isPending } = useSessionActions( + const { archiveSession, reopenSession, renameSession, deleteSession, isPending } = useSessionActions( api, session.id, session.metadata?.flavor ?? null ) + const [reopenError, setReopenError] = useState(null) const handleDelete = async () => { await deleteSession() onSessionDeleted?.() } + const handleReopen = async () => { + setReopenError(null) + try { + const result = await reopenSession() + if (result.sessionId && result.sessionId !== session.id) { + onSessionReopened?.(result.sessionId) + } + } catch (error) { + setReopenError(formatReopenError(error)) + } + } + const handleMenuToggle = () => { if (!menuOpen && menuAnchorRef.current) { const rect = menuAnchorRef.current.getBoundingClientRect() @@ -225,11 +240,25 @@ export function SessionHeader(props: { onRename={() => setRenameOpen(true)} onExport={() => setExportOpen(true)} onArchive={() => setArchiveOpen(true)} + onReopen={handleReopen} onDelete={() => setDeleteOpen(true)} anchorPoint={menuAnchorPoint} menuId={menuId} /> + {reopenError ? ( + setReopenError(null)} + title={t('dialog.reopen.errorTitle')} + description={reopenError} + confirmLabel={t('dialog.reopen.dismiss')} + confirmingLabel={t('dialog.reopen.dismiss')} + onConfirm={async () => setReopenError(null)} + isPending={false} + /> + ) : null} + setRenameOpen(false)} diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index bc39cc4d..2cf627ec 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -17,6 +17,7 @@ import { classifySessionAttention } from '@/lib/sessionAttention' import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' +import { formatReopenError } from '@/lib/reopenError' type SessionGroup = { key: string @@ -567,11 +568,26 @@ function SessionItem(props: { const [archiveOpen, setArchiveOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) - const { archiveSession, renameSession, deleteSession, isPending } = useSessionActions( + const { archiveSession, reopenSession, renameSession, deleteSession, isPending } = useSessionActions( api, s.id, s.metadata?.flavor ?? null ) + const [reopenError, setReopenError] = useState(null) + + const handleReopen = async () => { + setReopenError(null) + try { + const result = await reopenSession() + // resumeSession may merge the row into a freshly-spawned sessionId. + // Follow it so the operator lands on the live session. + if (result.sessionId && result.sessionId !== s.id) { + onSelect(result.sessionId) + } + } catch (error) { + setReopenError(formatReopenError(error)) + } + } const longPressHandlers = useLongPress({ onLongPress: (point) => { @@ -661,10 +677,24 @@ function SessionItem(props: { sessionActive={s.active} onRename={() => setRenameOpen(true)} onArchive={() => setArchiveOpen(true)} + onReopen={handleReopen} onDelete={() => setDeleteOpen(true)} anchorPoint={menuAnchorPoint} /> + {reopenError ? ( + setReopenError(null)} + title={t('dialog.reopen.errorTitle')} + description={reopenError} + confirmLabel={t('dialog.reopen.dismiss')} + confirmingLabel={t('dialog.reopen.dismiss')} + onConfirm={async () => setReopenError(null)} + isPending={false} + /> + ) : null} + setRenameOpen(false)} diff --git a/web/src/hooks/mutations/useSessionActions.test.tsx b/web/src/hooks/mutations/useSessionActions.test.tsx new file mode 100644 index 00000000..830eb00a --- /dev/null +++ b/web/src/hooks/mutations/useSessionActions.test.tsx @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { useSessionActions } from './useSessionActions' +import { ApiError, type ApiClient } from '@/api/client' + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +function createMockApi(reopenSession: (sessionId: string) => Promise<{ ok: true; sessionId: string; resumed: boolean }>): ApiClient { + return { reopenSession } as unknown as ApiClient +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useSessionActions - reopenSession', () => { + it('invokes api.reopenSession with the session id and forwards the response', async () => { + const reopen = vi.fn(async (_sessionId: string) => ({ + ok: true as const, + sessionId: 'session-A-spawned', + resumed: true + })) + const api = createMockApi(reopen) + + const { result } = renderHook( + () => useSessionActions(api, 'session-A', 'cursor'), + { wrapper: createWrapper() }, + ) + + let response: { ok: true; sessionId: string; resumed: boolean } | undefined + await act(async () => { + response = await result.current.reopenSession() + }) + + expect(reopen).toHaveBeenCalledWith('session-A') + // The mutation must propagate the response so the UI can navigate to the + // possibly-new spawn id when resumeSession merges the row. + expect(response).toEqual({ ok: true, sessionId: 'session-A-spawned', resumed: true }) + }) + + it('throws when api or sessionId is missing', async () => { + const { result } = renderHook( + () => useSessionActions(null, null, null), + { wrapper: createWrapper() }, + ) + + await expect(result.current.reopenSession()).rejects.toThrow('Session unavailable') + }) + + it('surfaces an ApiError so the UI can render the 422 missing-metadata payload', async () => { + const reopen = vi.fn(async () => { + throw new ApiError( + 'HTTP 422 Unprocessable Entity: {"error":"Cursor session id is missing from metadata; reopen requires the original cursor chat id","missing":["cursorSessionId"]}', + 422, + 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', + '{"error":"Cursor session id is missing from metadata; reopen requires the original cursor chat id","missing":["cursorSessionId"]}' + ) + }) + const api = createMockApi(reopen as unknown as ApiClient['reopenSession']) + + const { result } = renderHook( + () => useSessionActions(api, 'session-X', 'cursor'), + { wrapper: createWrapper() }, + ) + + let captured: unknown + await act(async () => { + try { + await result.current.reopenSession() + } catch (error) { + captured = error + } + }) + + expect(captured).toBeInstanceOf(ApiError) + const apiError = captured as ApiError + expect(apiError.status).toBe(422) + expect(apiError.body).toContain('cursorSessionId') + + await waitFor(() => { + // The hook should not get stuck pending after the failure. + expect(result.current.isPending).toBe(false) + }) + }) +}) diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 5642ee7f..fe96f37b 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' import type { ApiClient } from '@/api/client' import type { CodexCollaborationMode, PermissionMode } from '@/types/api' +import type { ReopenSessionResponse } from '@hapi/protocol/apiTypes' import { queryKeys } from '@/lib/query-keys' import { clearMessageWindow } from '@/lib/message-window-store' import { isKnownFlavor } from '@hapi/protocol' @@ -14,6 +15,7 @@ export function useSessionActions( ): { abortSession: () => Promise archiveSession: () => Promise + reopenSession: () => Promise switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise setCollaborationMode: (mode: CodexCollaborationMode) => Promise @@ -58,6 +60,16 @@ export function useSessionActions( onSuccess: () => void invalidateSession(), }) + const reopenMutation = useMutation({ + mutationFn: async () => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + return await api.reopenSession(sessionId) + }, + onSuccess: () => void invalidateSession(), + }) + const switchMutation = useMutation({ mutationFn: async () => { if (!api || !sessionId) { @@ -166,6 +178,7 @@ export function useSessionActions( return { abortSession: abortMutation.mutateAsync, archiveSession: archiveMutation.mutateAsync, + reopenSession: reopenMutation.mutateAsync, switchSession: switchMutation.mutateAsync, setPermissionMode: permissionMutation.mutateAsync, setCollaborationMode: collaborationMutation.mutateAsync, @@ -176,6 +189,7 @@ export function useSessionActions( deleteSession: deleteMutation.mutateAsync, isPending: abortMutation.isPending || archiveMutation.isPending + || reopenMutation.isPending || switchMutation.isPending || permissionMutation.isPending || collaborationMutation.isPending diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 44c585b9..812b4e00 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -133,6 +133,7 @@ export default { 'session.action.rename': 'Rename', 'session.action.export': 'Export conversation', 'session.action.archive': 'Archive', + 'session.action.reopen': 'Reopen', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', @@ -150,6 +151,8 @@ export default { 'dialog.archive.description': 'Are you sure you want to archive "{name}"? This will disconnect active session.', 'dialog.archive.confirm': 'Archive', 'dialog.archive.confirming': 'Archiving…', + 'dialog.reopen.errorTitle': 'Could not reopen session', + 'dialog.reopen.dismiss': 'Dismiss', 'dialog.delete.title': 'Delete Session', 'dialog.delete.description': 'Are you sure you want to delete "{name}"? This action cannot be undone.', 'dialog.delete.confirm': 'Delete', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2013fcea..c91029f1 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -133,6 +133,7 @@ export default { 'session.action.rename': '重命名', 'session.action.export': '导出对话', 'session.action.archive': '归档', + 'session.action.reopen': '重新打开', 'session.action.delete': '删除', 'session.action.copy': '复制', @@ -152,6 +153,9 @@ export default { 'dialog.archive.confirm': '归档', 'dialog.archive.confirming': '归档中…', + 'dialog.reopen.errorTitle': '无法重新打开会话', + 'dialog.reopen.dismiss': '关闭', + 'dialog.delete.title': '删除会话', 'dialog.delete.description': '确定要删除 "{name}" 吗?此操作无法撤销。', 'dialog.delete.confirm': '删除', diff --git a/web/src/lib/reopenError.test.ts b/web/src/lib/reopenError.test.ts new file mode 100644 index 00000000..55d7ebdf --- /dev/null +++ b/web/src/lib/reopenError.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { ApiError } from '@/api/client' +import { formatReopenError } from './reopenError' + +describe('formatReopenError', () => { + it('renders error + missing fields from a 422 ApiError body', () => { + const error = new ApiError( + 'HTTP 422 Unprocessable Entity: {"error":"Cursor session id is missing","missing":["cursorSessionId"]}', + 422, + 'Cursor session id is missing', + '{"error":"Cursor session id is missing","missing":["cursorSessionId"]}' + ) + + expect(formatReopenError(error)).toBe( + 'Cursor session id is missing (missing: cursorSessionId)' + ) + }) + + it('renders error only when missing is empty', () => { + const error = new ApiError( + 'HTTP 503 Service Unavailable: {"error":"No machine online","code":"no_machine_online"}', + 503, + 'no_machine_online', + '{"error":"No machine online","code":"no_machine_online"}' + ) + + expect(formatReopenError(error)).toBe('No machine online') + }) + + it('falls back to Error.message when there is no JSON body to parse', () => { + expect(formatReopenError(new Error('boom'))).toBe('boom') + }) + + it('falls back to a generic message when the value is not an Error', () => { + expect(formatReopenError('plain string')).toBe('Failed to reopen session') + }) + + it('parses JSON embedded in plain Error messages when no ApiError body is set', () => { + // Older callers wrap the body in the Error message string. + const error = new Error( + 'HTTP 422: {"error":"Cursor session id is missing","missing":["cursorSessionId","cursorSessionProtocol"]}' + ) + + expect(formatReopenError(error)).toBe( + 'Cursor session id is missing (missing: cursorSessionId, cursorSessionProtocol)' + ) + }) + + it('falls back to the raw message when JSON cannot be parsed', () => { + const error = new Error('HTTP 500: not actually json {bad}') + expect(formatReopenError(error)).toBe('HTTP 500: not actually json {bad}') + }) +}) diff --git a/web/src/lib/reopenError.ts b/web/src/lib/reopenError.ts new file mode 100644 index 00000000..7259f634 --- /dev/null +++ b/web/src/lib/reopenError.ts @@ -0,0 +1,35 @@ +import { ApiError } from '@/api/client' + +/** + * Extract a human-readable message from a Reopen failure. + * + * The hub returns `{ error, missing: [...] }` on 422 when required metadata + * is gone (e.g. Cursor session lacks `cursorSessionId`). For other errors the + * body is `{ error, code? }`. Both shapes are surfaced verbatim; we fall back + * to the raw `Error.message` when the body is unparseable or absent. + */ +export function formatReopenError(error: unknown): string { + const fallback = error instanceof Error ? error.message : 'Failed to reopen session' + + const body = error instanceof ApiError ? error.body : undefined + const source = body ?? extractJsonFromMessage(fallback) + if (!source) return fallback + + try { + const parsed = JSON.parse(source) as { error?: string; missing?: string[] } + if (parsed.error && Array.isArray(parsed.missing) && parsed.missing.length > 0) { + return `${parsed.error} (missing: ${parsed.missing.join(', ')})` + } + if (parsed.error) { + return parsed.error + } + } catch { + // body was not JSON; fall through + } + return fallback +} + +function extractJsonFromMessage(message: string): string | undefined { + const start = message.indexOf('{') + return start === -1 ? undefined : message.slice(start) +}