diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 6253566e..a28b6931 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync, mkdirSync, realpathSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' const ioMock = vi.hoisted(() => vi.fn()) const listOpencodeModelsForCwdMock = vi.hoisted(() => vi.fn()) const listGrokModelsForCwdMock = vi.hoisted(() => vi.fn()) +const inspectCursorChatStoreMock = vi.hoisted(() => vi.fn()) vi.mock('socket.io-client', () => ({ io: ioMock @@ -23,6 +24,10 @@ vi.mock('../modules/common/grokModels', () => ({ listGrokModelsForCwd: listGrokModelsForCwdMock })) +vi.mock('@/cursor/cursorChatStoreStatus', () => ({ + inspectCursorChatStore: inspectCursorChatStoreMock +})) + import { ApiMachineClient, normalizeWindowsDriveRoot } from './apiMachine' import type { Machine } from './types' @@ -74,6 +79,77 @@ async function callListGrokModels(client: ApiMachineClient, machineId: string, c return JSON.parse(raw) as unknown } +async function callCursorChatStoreStatus( + client: ApiMachineClient, + machineId: string, + params: { workspacePath: string; cursorSessionId: string; homeDir?: string } +): Promise { + const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise } }).rpcHandlerManager + const raw = await manager.handleRequest({ + method: `${machineId}:cursor-chat-store-status`, + params: JSON.stringify(params) + }) + return JSON.parse(raw) as unknown +} + +describe('ApiMachineClient cursor-chat-store-status handler', () => { + beforeEach(() => { + inspectCursorChatStoreMock.mockReset() + inspectCursorChatStoreMock.mockResolvedValue({ onDisk: false, store: null }) + }) + + it('inspects stores under the recorded session owner home', async () => { + const machine = makeMachine('cursor-store-machine') + const client = new ApiMachineClient('cli-token', machine) + + try { + await callCursorChatStoreStatus(client, machine.id, { + workspacePath: '/work/project', + cursorSessionId: 'cursor-session', + homeDir: ' /home/recorded-owner ' + }) + + expect(inspectCursorChatStoreMock).toHaveBeenCalledWith({ + home: '/home/recorded-owner', + workspacePath: '/work/project', + cursorSessionId: 'cursor-session' + }) + } finally { + client.shutdown() + } + }) + + it('falls back to the CLI process home for old or whitespace-only homeDir metadata', async () => { + const machine = makeMachine('cursor-store-fallback-machine') + const client = new ApiMachineClient('cli-token', machine) + + try { + await callCursorChatStoreStatus(client, machine.id, { + workspacePath: '/work/project', + cursorSessionId: 'cursor-session-old' + }) + await callCursorChatStoreStatus(client, machine.id, { + workspacePath: '/work/project', + cursorSessionId: 'cursor-session-empty', + homeDir: ' ' + }) + + expect(inspectCursorChatStoreMock).toHaveBeenNthCalledWith(1, { + home: homedir(), + workspacePath: '/work/project', + cursorSessionId: 'cursor-session-old' + }) + expect(inspectCursorChatStoreMock).toHaveBeenNthCalledWith(2, { + home: homedir(), + workspacePath: '/work/project', + cursorSessionId: 'cursor-session-empty' + }) + } finally { + client.shutdown() + } + }) +}) + describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { let workspaceRoot: string diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index f0e67bd2..6da0a37e 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -31,6 +31,9 @@ import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/ import { applyVersionedAck } from './versionedUpdate' import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' import { collectMachineHealth } from '@/utils/machineHealth' +import { inspectCursorChatStore } from '@/cursor/cursorChatStoreStatus' +import { homedir } from 'node:os' +import type { CursorChatStoreStatus } from '@hapi/protocol/apiTypes' type MachineRpcHandlers = { spawnSession: (options: SpawnSessionOptions) => Promise @@ -46,6 +49,12 @@ interface ListMachineDirectoryRequest { path: string } +interface CursorChatStoreStatusRequest { + workspacePath: string + cursorSessionId: string + homeDir?: string +} + export function normalizeWindowsDriveRoot(path: string): string { return /^[A-Za-z]:$/.test(path) ? `${path}\\` : path } @@ -128,6 +137,18 @@ export class ApiMachineClient { return { exists } }) + this.rpcHandlerManager.registerHandler( + RPC_METHODS.CursorChatStoreStatus, + async (params) => { + const recordedHome = typeof params?.homeDir === 'string' ? params.homeDir.trim() : '' + return await inspectCursorChatStore({ + home: recordedHome || homedir(), + workspacePath: typeof params?.workspacePath === 'string' ? params.workspacePath : '', + cursorSessionId: typeof params?.cursorSessionId === 'string' ? params.cursorSessionId : '' + }) + } + ) + this.rpcHandlerManager.registerHandler(RPC_METHODS.ListMachineDirectory, async (params) => { if (!this.normalizedWorkspaceRoots?.length) { return { success: false, error: 'Workspace browsing is not enabled for this machine' } diff --git a/cli/src/cursor/cursorChatStoreStatus.test.ts b/cli/src/cursor/cursorChatStoreStatus.test.ts new file mode 100644 index 00000000..321415dc --- /dev/null +++ b/cli/src/cursor/cursorChatStoreStatus.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { createHash, randomUUID } from 'node:crypto' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { inspectCursorChatStore } from './cursorChatStoreStatus' + +const homes: string[] = [] + +async function makeHome(): Promise { + const home = join(tmpdir(), `hapi-cursor-store-${randomUUID()}`) + homes.push(home) + await mkdir(home, { recursive: true }) + return home +} + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) +}) + +describe('inspectCursorChatStore', () => { + it('finds ACP store.db under the runner user home', async () => { + const home = await makeHome() + const store = join(home, '.cursor', 'acp-sessions', 'cursor-1', 'store.db') + await mkdir(join(store, '..'), { recursive: true }) + await writeFile(store, 'db') + + await expect(inspectCursorChatStore({ + home, + workspacePath: '/work/project', + cursorSessionId: 'cursor-1' + })).resolves.toEqual({ onDisk: true, store: 'acp' }) + }) + + it('finds legacy store.db by Cursor workspace hash', async () => { + const home = await makeHome() + const workspacePath = '/work/project' + const workspaceHash = createHash('md5').update(workspacePath).digest('hex') + const store = join(home, '.cursor', 'chats', workspaceHash, 'cursor-2', 'store.db') + await mkdir(join(store, '..'), { recursive: true }) + await writeFile(store, 'db') + + await expect(inspectCursorChatStore({ + home, + workspacePath, + cursorSessionId: 'cursor-2' + })).resolves.toEqual({ onDisk: true, store: 'legacy' }) + }) + + it('hashes the raw workspace path without trimming valid path bytes', async () => { + const home = await makeHome() + const workspacePath = '/work/project ' + const workspaceHash = createHash('md5').update(workspacePath).digest('hex') + const stores = [ + join(home, '.cursor', 'chats', workspaceHash, 'cursor-spaced-path', 'store.db'), + join(home, '.cursor', 'chats', 'stale-workspace-hash', 'cursor-spaced-path', 'store.db') + ] + for (const store of stores) { + await mkdir(join(store, '..'), { recursive: true }) + await writeFile(store, 'db') + } + + await expect(inspectCursorChatStore({ + home, + workspacePath, + cursorSessionId: 'cursor-spaced-path' + })).resolves.toEqual({ onDisk: true, store: 'legacy' }) + }) + + it('finds a unique legacy store when the canonical workspace drawer is missing', async () => { + const home = await makeHome() + const store = join(home, '.cursor', 'chats', 'legacy-workspace-hash', 'cursor-3', 'store.db') + await mkdir(join(store, '..'), { recursive: true }) + await writeFile(store, 'db') + + await expect(inspectCursorChatStore({ + home, + workspacePath: '/work/project-moved-since-chat-was-created', + cursorSessionId: 'cursor-3' + })).resolves.toEqual({ onDisk: true, store: 'legacy' }) + }) + + it('reports missing when multiple non-canonical legacy stores are present', async () => { + const home = await makeHome() + const stores = [ + join(home, '.cursor', 'chats', 'workspace-hash-a', 'cursor-4', 'store.db'), + join(home, '.cursor', 'chats', 'workspace-hash-b', 'cursor-4', 'store.db') + ] + for (const store of stores) { + await mkdir(join(store, '..'), { recursive: true }) + await writeFile(store, 'db') + } + + await expect(inspectCursorChatStore({ + home, + workspacePath: '/work/unrelated-project', + cursorSessionId: 'cursor-4' + })).resolves.toEqual({ onDisk: false, store: null }) + }) + + it('reports missing without allowing cursorSessionId path traversal', async () => { + const home = await makeHome() + + await expect(inspectCursorChatStore({ + home, + workspacePath: '/work/project', + cursorSessionId: '../../outside' + })).resolves.toEqual({ onDisk: false, store: null }) + }) +}) diff --git a/cli/src/cursor/cursorChatStoreStatus.ts b/cli/src/cursor/cursorChatStoreStatus.ts new file mode 100644 index 00000000..533da4f7 --- /dev/null +++ b/cli/src/cursor/cursorChatStoreStatus.ts @@ -0,0 +1,86 @@ +import { createHash } from 'node:crypto' +import { readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { CursorChatStoreStatus } from '@hapi/protocol/apiTypes' + +type InspectCursorChatStoreOptions = { + home: string + workspacePath: string + cursorSessionId: string +} + +function isSafeCursorSessionId(value: string): boolean { + return value !== '.' + && value !== '..' + && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch { + return false + } +} + +async function hasUniqueLegacyStore(home: string, cursorSessionId: string): Promise { + const chatsRoot = join(home, '.cursor', 'chats') + let workspaceDrawers: string[] + try { + workspaceDrawers = await readdir(chatsRoot) + } catch { + return false + } + + let matches = 0 + for (const workspaceDrawer of workspaceDrawers) { + const candidate = join(chatsRoot, workspaceDrawer, cursorSessionId, 'store.db') + if (await isFile(candidate)) { + matches += 1 + if (matches > 1) { + return false + } + } + } + return matches === 1 +} + +export async function inspectCursorChatStore( + options: InspectCursorChatStoreOptions +): Promise { + const cursorSessionId = options.cursorSessionId.trim() + const workspacePath = options.workspacePath + if (!isSafeCursorSessionId(cursorSessionId) || workspacePath.length === 0) { + return { onDisk: false, store: null } + } + + const acpStore = join( + options.home, + '.cursor', + 'acp-sessions', + cursorSessionId, + 'store.db' + ) + if (await isFile(acpStore)) { + return { onDisk: true, store: 'acp' } + } + + const workspaceHash = createHash('md5').update(workspacePath).digest('hex') + const legacyStore = join( + options.home, + '.cursor', + 'chats', + workspaceHash, + cursorSessionId, + 'store.db' + ) + if (await isFile(legacyStore)) { + return { onDisk: true, store: 'legacy' } + } + + if (await hasUniqueLegacyStore(options.home, cursorSessionId)) { + return { onDisk: true, store: 'legacy' } + } + + return { onDisk: false, store: null } +} diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts index d0825c0d..50121366 100644 --- a/hub/src/sync/rpcGateway.test.ts +++ b/hub/src/sync/rpcGateway.test.ts @@ -5,11 +5,16 @@ import { RpcGateway, RpcTargetMissingError } from './rpcGateway' function createGateway() { const timeouts: number[] = [] + const calls: Array<{ method: string; params: string }> = [] const socket = { timeout(timeoutMs: number) { timeouts.push(timeoutMs) return { async emitWithAck(_event: string, payload: { method: string; params: string }) { + calls.push(payload) + if (payload.method.endsWith(':cursor-chat-store-status')) { + return JSON.stringify({ onDisk: false, store: null }) + } return JSON.stringify({ success: true, method: payload.method, @@ -40,7 +45,8 @@ function createGateway() { return { gateway: new RpcGateway(io, rpcRegistry), - timeouts + timeouts, + calls } } @@ -68,6 +74,26 @@ describe('RpcGateway RPC timeouts', () => { expect(timeouts).toEqual([120_000]) }) + + it('forwards the recorded session owner home to the Cursor store probe', async () => { + const { gateway, calls } = createGateway() + + await gateway.getCursorChatStoreStatus( + 'machine-1', + '/workspace/project', + 'cursor-session', + '/home/recorded-owner' + ) + + expect(calls).toEqual([{ + method: 'machine-1:cursor-chat-store-status', + params: JSON.stringify({ + workspacePath: '/workspace/project', + cursorSessionId: 'cursor-session', + homeDir: '/home/recorded-owner' + }) + }]) + }) }) // tiann/hapi#916: rpcCall throws a typed `RpcTargetMissingError` when the @@ -114,4 +140,3 @@ describe('RpcGateway no-target diagnostics (tiann/hapi#916)', () => { expect((error as RpcTargetMissingError).code).toBe('socket-disconnected') }) }) - diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 482167cb..daadb9a7 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -1,11 +1,13 @@ import type { AgentFlavor, CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { CursorChatStoreStatusSchema } from '@hapi/protocol/apiTypes' import type { CodexModelSummary, CodexModelsResponse, CommandResponse, CursorModelSummary, CursorModelsResponse, + CursorChatStoreStatus, DeleteUploadResponse, DirectoryEntry, FileReadResponse, @@ -59,6 +61,7 @@ export type RpcCodexModel = CodexModelSummary export type RpcListCodexModelsResponse = CodexModelsResponse export type RpcCursorModel = CursorModelSummary export type RpcListCursorModelsResponse = CursorModelsResponse +export type RpcCursorChatStoreStatus = CursorChatStoreStatus export type RpcOpencodeModel = OpencodeModelSummary export type RpcListOpencodeModelsResponse = OpencodeModelsResponse export type RpcListGrokModelsResponse = GrokModelsResponse @@ -210,6 +213,20 @@ export class RpcGateway { return exists } + async getCursorChatStoreStatus( + machineId: string, + workspacePath: string, + cursorSessionId: string, + homeDir?: string + ): Promise { + const result = await this.machineRpc( + machineId, + RPC_METHODS.CursorChatStoreStatus, + { workspacePath, cursorSessionId, homeDir } + ) + return CursorChatStoreStatusSchema.parse(result) + } + async getGitStatus(sessionId: string, cwd?: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.GitStatus, { cwd }) as RpcCommandResponse } diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 7818b30f..a44bbe4d 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1187,6 +1187,7 @@ describe('session model', () => { engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() }) return { type: 'success', sessionId: spawnedSessionId } } + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => ({ onDisk: true, store: 'acp' }) ;(engine as any).waitForSessionActive = async () => true ;(engine as any).waitForSessionReady = async () => 'ended' @@ -1319,6 +1320,7 @@ describe('session model', () => { engine.handleSessionReady({ sid: spawnedSessionId, time: Date.now() }) return { type: 'success', sessionId: spawnedSessionId } } + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => ({ onDisk: true, store: 'acp' }) ;(engine as any).waitForSessionActive = async () => true const result = await engine.resumeSession(oldSession.id, 'default') @@ -1387,6 +1389,7 @@ describe('session model', () => { engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() }) return { type: 'success', sessionId: spawnedSessionId } } + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => ({ onDisk: true, store: 'legacy' }) ;(engine as any).waitForSessionActive = async () => true const result = await engine.resumeSession(oldSession.id, 'default') @@ -1598,6 +1601,240 @@ describe('session model', () => { } }) + it('refuses Cursor resume before spawning when the recorded chat store is missing', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'cursor-missing-store', + { + path: '/tmp/project', + host: 'cursor-host', + machineId: 'cursor-machine', + homeDir: '/home/cursor-owner', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-missing', + 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 + let probeArgs: unknown[] | null = null + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async (...args: unknown[]) => { + probeArgs = args + return { onDisk: false, store: null } + } + ;(engine as any).rpcGateway.spawnSession = async () => { + spawnCalled = true + return { type: 'success', sessionId: session.id } + } + + const result = await engine.resumeSession(session.id, 'default') + + expect(result).toEqual({ + type: 'error', + message: 'Cursor chat data is no longer available on the recorded machine', + code: 'resume_unavailable' + }) + expect(probeArgs as unknown).toEqual([ + 'cursor-machine', + '/tmp/project', + 'cursor-thread-missing', + '/home/cursor-owner' + ]) + expect(spawnCalled).toBe(false) + } finally { + engine.stop() + } + }) + + it('probes Cursor chat data on the session recorded machine', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'cursor-machine-scoped-store', + { + path: '/remote/project', + host: 'shared-host-label', + machineId: 'recorded-machine', + homeDir: '/home/recorded-owner', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-remote', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + for (const machineId of ['other-machine', 'recorded-machine']) { + engine.getOrCreateMachine( + machineId, + { host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId, time: Date.now() }) + } + + let captured: unknown[] | null = null + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async (...args: unknown[]) => { + captured = args + return { onDisk: true, store: 'acp' } + } + + const result = await engine.getCursorChatStoreStatus(session.id, 'default') + + expect(result).toEqual({ + type: 'success', + status: { onDisk: true, store: 'acp' } + }) + expect(captured as unknown).toEqual([ + 'recorded-machine', + '/remote/project', + 'cursor-thread-remote', + '/home/recorded-owner' + ]) + } finally { + engine.stop() + } + }) + + it('does not probe a same-host machine when the recorded Cursor machine is offline', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'cursor-offline-recorded-machine-status', + { + path: '/remote/project', + host: 'shared-host-label', + machineId: 'recorded-machine-offline', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-offline', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'recorded-machine-offline', + { host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'wrong-same-host-machine', + { host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'wrong-same-host-machine', time: Date.now() }) + + let probeCalled = false + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => { + probeCalled = true + return { onDisk: true, store: 'acp' } + } + + expect(await engine.getCursorChatStoreStatus(session.id, 'default')).toEqual({ + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + }) + expect(probeCalled).toBe(false) + } finally { + engine.stop() + } + }) + + it('does not probe or spawn on a same-host machine when the recorded Cursor machine is offline', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'cursor-offline-recorded-machine-resume', + { + path: '/remote/project', + host: 'shared-host-label', + machineId: 'recorded-machine-offline', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-offline', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'recorded-machine-offline', + { host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'wrong-same-host-machine', + { host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'wrong-same-host-machine', time: Date.now() }) + + let probeCalled = false + let spawnCalled = false + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => { + probeCalled = true + return { onDisk: true, store: 'acp' } + } + ;(engine as any).rpcGateway.spawnSession = async () => { + spawnCalled = true + return { type: 'success', sessionId: session.id } + } + ;(engine as any).waitForSessionActive = async () => true + + expect(await engine.resumeSession(session.id, 'default')).toEqual({ + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + }) + expect(probeCalled).toBe(false) + expect(spawnCalled).toBe(false) + } finally { + engine.stop() + } + }) + it('resumeSession fresh-spawns when inactive cursor session has no agent id and no user messages', 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 1ca672db..7118e531 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -8,7 +8,7 @@ */ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol' -import type { CursorMigrateOutcome, CursorMigrateToAcpRequest, SlashCommandsResponse } from '@hapi/protocol/apiTypes' +import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' @@ -36,6 +36,7 @@ import { type RpcListGrokReasoningEffortOptionsResponse, type RpcListOpencodeReasoningEffortOptionsResponse, type RpcCursorModel, + type RpcCursorChatStoreStatus, type RpcOpencodeModel, type RpcPathExistsResponse, type RpcReadFileResponse, @@ -59,6 +60,7 @@ export type { RpcListGrokReasoningEffortOptionsResponse, RpcListOpencodeReasoningEffortOptionsResponse, RpcCursorModel, + RpcCursorChatStoreStatus, RpcOpencodeModel, RpcPathExistsResponse, RpcReadFileResponse, @@ -82,6 +84,10 @@ export type LocalHandoffResult = | { type: 'success' } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' } +export type CursorChatStoreStatusResult = + | { type: 'success'; status: CursorChatStoreStatus } + | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'resume_unavailable' | 'no_machine_online' | 'probe_failed' } + function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record @@ -189,6 +195,69 @@ export class SyncEngine { return this.sessionCache.getSessions() } + private resolveOnlineMachineForSession( + session: Session, + namespace: string, + options?: { strictMachineId?: boolean } + ): Machine | null { + const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) + if (session.metadata?.machineId) { + const exact = onlineMachines.find((machine) => machine.id === session.metadata?.machineId) + if (exact) return exact + if (options?.strictMachineId) return null + } + if (session.metadata?.host) { + const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === session.metadata?.host) + if (hostMatch) return hostMatch + } + return null + } + + async getCursorChatStoreStatus(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 metadata = access.session.metadata + if (metadata?.flavor !== 'cursor' || !metadata.path || !metadata.cursorSessionId) { + return { + type: 'error', + message: 'Cursor resume metadata is unavailable', + code: 'resume_unavailable' + } + } + + const targetMachine = this.resolveOnlineMachineForSession( + access.session, + namespace, + { strictMachineId: true } + ) + if (!targetMachine) { + return { type: 'error', message: 'No machine online', code: 'no_machine_online' } + } + + try { + const status = await this.rpcGateway.getCursorChatStoreStatus( + targetMachine.id, + metadata.path, + metadata.cursorSessionId, + metadata.homeDir + ) + return { type: 'success', status } + } catch (error) { + return { + type: 'error', + message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store', + code: 'probe_failed' + } + } + } + getSessionsByNamespace(namespace: string): Session[] { return this.sessionCache.getSessionsByNamespace(namespace) } @@ -1168,25 +1237,37 @@ export class SyncEngine { const metadata = session.metadata! - const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) - if (onlineMachines.length === 0) { + const targetMachine = this.resolveOnlineMachineForSession( + session, + namespace, + { strictMachineId: flavor === 'cursor' } + ) + if (!targetMachine) { return { type: 'error', message: 'No machine online', code: 'no_machine_online' } } - const targetMachine = (() => { - if (metadata.machineId) { - const exact = onlineMachines.find((machine) => machine.id === metadata.machineId) - if (exact) return exact + if (flavor === 'cursor' && resumeToken) { + try { + const chatStatus = await this.rpcGateway.getCursorChatStoreStatus( + targetMachine.id, + directory, + resumeToken, + metadata.homeDir + ) + if (!chatStatus.onDisk) { + return { + type: 'error', + message: 'Cursor chat data is no longer available on the recorded machine', + code: 'resume_unavailable' + } + } + } catch (error) { + return { + type: 'error', + message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store', + code: 'resume_failed' + } } - if (metadata.host) { - const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === metadata.host) - if (hostMatch) return hostMatch - } - return null - })() - - if (!targetMachine) { - return { type: 'error', message: 'No machine online', code: 'no_machine_online' } } const preferredPermissionMode = opts?.permissionMode diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 8db9c76e..c51c9942 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -63,6 +63,7 @@ function createApp(session: Session, opts?: { getSessionExport?: (sessionId: string, session: Session) => unknown sessionExists?: boolean archiveSession?: (sessionId: string) => Promise + getCursorChatStoreStatus?: SyncEngine['getCursorChatStoreStatus'] }) { const applySessionConfigCalls: Array<[string, Record]> = [] const applySessionConfig = async (sessionId: string, config: Record) => { @@ -135,6 +136,10 @@ function createApp(session: Session, opts?: { listGrokReasoningEffortOptionsForSession, resumeSession, reopenSession, + getCursorChatStoreStatus: opts?.getCursorChatStoreStatus ?? (async () => ({ + type: 'success' as const, + status: { onDisk: true, store: 'acp' as const } + })), archiveSession: archiveSessionMock, getSessionExport: opts?.getSessionExport ?? (() => ({ type: 'success', @@ -162,6 +167,29 @@ function createApp(session: Session, opts?: { } describe('sessions routes', () => { + it('returns the machine-scoped Cursor chat store status', async () => { + const session = createSession({ + active: false, + metadata: { + path: '/tmp/project', + host: 'cursor-host', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-1' + } + }) + const { app } = createApp(session, { + getCursorChatStoreStatus: async () => ({ + type: 'success', + status: { onDisk: false, store: null } + }) + }) + + const response = await app.request('/api/sessions/session-1/cursor-chat-store') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ onDisk: false, store: null }) + }) + it('exports an empty session conversation payload', async () => { const session = createSession() const { app } = createApp(session) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index c5f64f81..c6e23bb6 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -134,6 +134,33 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ session: sessionResult.session }) }) + app.get('/sessions/:id/cursor-chat-store', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const result = await engine.getCursorChatStoreStatus( + sessionResult.sessionId, + c.get('namespace') + ) + if (result.type === 'error') { + const status = result.code === 'session_not_found' ? 404 + : result.code === 'access_denied' ? 403 + : result.code === 'resume_unavailable' ? 409 + : result.code === 'no_machine_online' ? 503 + : 502 + return c.json({ error: result.message, code: result.code }, status) + } + + return c.json(result.status) + }) + app.post('/sessions/:id/resume', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index bb2997cf..fea310b2 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -119,6 +119,13 @@ export const ReopenSessionMissingMetadataResponseSchema = z.object({ export type ReopenSessionMissingMetadataResponse = z.infer +export const CursorChatStoreStatusSchema = z.object({ + onDisk: z.boolean(), + store: z.enum(['legacy', 'acp']).nullable() +}) + +export type CursorChatStoreStatus = z.infer + export const SessionCollaborationModeRequestSchema = z.object({ mode: CodexCollaborationModeSchema }) diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 13d22794..7e6d2f09 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -10,6 +10,7 @@ export const RPC_METHODS = { StopRunner: 'stop-runner', ListMachineDirectory: 'list-directory', PathExists: 'path-exists', + CursorChatStoreStatus: 'cursor-chat-store-status', GitStatus: 'git-status', GitDiffNumstat: 'git-diff-numstat', GitDiffFile: 'git-diff-file', diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 343e3336..3297c2c8 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -79,4 +79,17 @@ describe('ApiClient error mapping', () => { expect(apiError.body).toContain('cursorSessionId') } }) + + it('loads the Cursor chat store status for the selected session', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ onDisk: false, store: null }), { status: 200 }) + ) + + const api = new ApiClient('test-token') + await expect(api.getCursorChatStoreStatus('session cursor')).resolves.toEqual({ + onDisk: false, + store: null + }) + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/sessions/session%20cursor/cursor-chat-store') + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index dbbdcd87..a99dcfe7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -27,6 +27,7 @@ import type { CodexModelsResponse, CursorMigrateOutcome, CursorMigrateToAcpRequest, + CursorChatStoreStatus, CursorModelsResponse, DeleteUploadResponse, FileReadResponse, @@ -389,6 +390,12 @@ export class ApiClient { return response.sessionId } + async getCursorChatStoreStatus(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/cursor-chat-store` + ) + } + async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[], scheduledAt?: number | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: 'POST', diff --git a/web/src/components/SessionActionMenu.test.tsx b/web/src/components/SessionActionMenu.test.tsx index ec53b0f1..7ae9d13b 100644 --- a/web/src/components/SessionActionMenu.test.tsx +++ b/web/src/components/SessionActionMenu.test.tsx @@ -52,6 +52,23 @@ describe('SessionActionMenu - Reopen action', () => { expect(screen.getByRole('menuitem', { name: /Delete/ })).toBeInTheDocument() }) + it('renders a disabled Reopen item with an explanation when resume data is missing', () => { + const onClose = vi.fn() + renderMenu({ + sessionActive: false, + onReopen: undefined, + reopenDisabledReason: 'Cursor chat data is no longer available on this machine.', + onClose, + }) + + const reopen = screen.getByRole('menuitem', { name: /Reopen/ }) + expect(reopen).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('tooltip')).toHaveTextContent('Cursor chat data is no longer available') + + fireEvent.click(reopen) + expect(onClose).not.toHaveBeenCalled() + }) + 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 9a958b40..ce02357e 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -8,6 +8,7 @@ import { type CSSProperties } from 'react' import { useTranslation } from '@/lib/use-translation' +import { HoverTooltip } from '@/components/HoverTooltip' type SessionActionMenuProps = { isOpen: boolean @@ -17,6 +18,7 @@ type SessionActionMenuProps = { onExport?: () => void onArchive: () => void onReopen?: () => void + reopenDisabledReason?: string onDelete: () => void anchorPoint: { x: number; y: number } menuId?: string @@ -143,6 +145,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { onExport, onArchive, onReopen, + reopenDisabledReason, onDelete, anchorPoint, menuId @@ -318,16 +321,30 @@ export function SessionActionMenu(props: SessionActionMenuProps) { ) : ( <> - {onReopen ? ( - + )} > - - {t('session.action.reopen')} - + {reopenDisabledReason ?? t('session.action.reopen')} + ) : null}