Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

358 changes: 358 additions & 0 deletions hub/src/cursor/acpVerifyProbe.test.ts

Large diffs are not rendered by default.

671 changes: 671 additions & 0 deletions hub/src/cursor/acpVerifyProbe.ts

Large diffs are not rendered by default.

1,139 changes: 1,139 additions & 0 deletions hub/src/cursor/cursorLegacyMigrator.test.ts

Large diffs are not rendered by default.

1,043 changes: 1,043 additions & 0 deletions hub/src/cursor/cursorLegacyMigrator.ts

Large diffs are not rendered by default.

277 changes: 277 additions & 0 deletions hub/src/cursor/cursorLegacyMigratorIntegration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/**
* Integration test for the legacy stream-json → ACP migrator.
*
* Spawns a REAL `agent acp` against an isolated $HOME with a synthetic
* legacy store.db. Verifies that:
* - initialize succeeds
* - session/load succeeds against the transplanted store
* - one session/prompt completes
*
* This is the same verify recipe the production migrator runs in its
* temp-HOME staging step. The test exists to detect drift between the
* cursor-agent on the developer's machine and HAPI's assumptions about
* its on-disk layout (#824).
*
* Opt-in: set CURSOR_AGENT_INTEGRATION=1 to enable. In CI without auth,
* keep this off - the unit tests in cursorLegacyMigrator.test.ts cover
* every migrator branch with mocks.
*
* Developer recipe:
* CURSOR_AGENT_INTEGRATION=1 bun test src/cursor/cursorLegacyMigratorIntegration.test.ts
*
* Fodder-strength: if LEGACY_FODDER_WSH + LEGACY_FODDER_UUID are also set,
* the test will copy that real on-disk legacy store into the fake $HOME and
* verify it survives the full migrator round-trip. The operator's real
* ~/.cursor/chats/ is NOT mutated.
*/

import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { mkdtempSync, mkdirSync, rmSync, copyFileSync, existsSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { homedir, tmpdir } from 'node:os'
import { spawnSync } from 'node:child_process'

import type { Metadata } from '@hapi/protocol/schemas'
import type { Session } from '@hapi/protocol/types'
import { CursorLegacyMigrator } from './cursorLegacyMigrator'
import { AcpVerifyProbe, tryAcquireAcpActiveLock } from './acpVerifyProbe'
import { buildSyntheticLegacyStore } from './fixtures/buildSyntheticLegacyStore'

const ENABLED = process.env.CURSOR_AGENT_INTEGRATION === '1'

function agentBinaryAvailable(): boolean {
const which = spawnSync('agent', ['--version'], { stdio: 'pipe' })
return which.status === 0
}

function copyAuthFiles(realHome: string, fakeHome: string): void {
const realCursor = join(realHome, '.cursor')
const fakeCursor = join(fakeHome, '.cursor')
mkdirSync(fakeCursor, { recursive: true })
for (const f of ['cli-config.json', 'agent-cli-state.json', 'acp-config.json']) {
const src = join(realCursor, f)
if (existsSync(src)) {
try { copyFileSync(src, join(fakeCursor, f)) } catch {}
}
}
}

const describeIntegration = ENABLED ? describe : describe.skip

describeIntegration('CursorLegacyMigrator INTEGRATION (real agent acp)', () => {
let fakeHome: string
let tmp: string
beforeEach(() => {
if (!ENABLED) return
if (!agentBinaryAvailable()) {
throw new Error('agent binary not on PATH; install cursor-agent or unset CURSOR_AGENT_INTEGRATION')
}
fakeHome = mkdtempSync(join(tmpdir(), 'hapi-migrator-integration-home-'))
tmp = mkdtempSync(join(tmpdir(), 'hapi-migrator-integration-tmp-'))
copyAuthFiles(homedir(), fakeHome)
mkdirSync(join(fakeHome, '.cursor', 'chats'), { recursive: true })
mkdirSync(join(fakeHome, '.cursor', 'acp-sessions'), { recursive: true })
})
afterEach(() => {
if (!ENABLED) return
try { rmSync(fakeHome, { recursive: true, force: true }) } catch {}
try { rmSync(tmp, { recursive: true, force: true }) } catch {}
})

it('migrates a tiny synthetic legacy store through the real agent acp verify path', async () => {
const cursorSessionId = '11111111-2222-3333-4444-555555555555'
const wsh = 'wsh-int'
const sourceDir = join(fakeHome, '.cursor', 'chats', wsh, cursorSessionId)
mkdirSync(sourceDir, { recursive: true })
const sourceStore = join(sourceDir, 'store.db')
buildSyntheticLegacyStore({ path: sourceStore, name: 'integration synthetic', lastUsedModel: 'composer-2.5' })

const updateCalls: Array<{ sessionId: string; namespace: string; lastUsedModel: string | null }> = []
const migrator = new CursorLegacyMigrator(
{ verifyTimeoutMs: 120_000, verifyPromptText: 'Reply with exactly: ack' },
{
homeDir: () => fakeHome,
hostName: () => "integration",
tmpDir: () => tmp,
now: () => Date.now(),
createProbe: (env) => new AcpVerifyProbe({ env, timeoutMs: 60_000, hapiHome: tmp, skipLockAcquire: true }),
awaitLockRelease: async () => true,
isAgentAcpTransportActive: () => ({ active: false, holderPid: null }),
acquireAcpActiveLock: () => tryAcquireAcpActiveLock(tmp),
archiveSession: async () => {},
updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => {
updateCalls.push({ sessionId, namespace, lastUsedModel })
return { ok: true }
}
}
)

const session: Session = {
id: 'integration-sess',
tag: 'integration-sess',
namespace: 'default',
createdAt: 0,
updatedAt: 0,
seq: 0,
metadataVersion: 1,
agentStateVersion: 1,
metadata: {
path: tmpdir(),
host: 'integration',
flavor: 'cursor',
cursorSessionId
} as Metadata,
active: false,
model: null,
modelReasoningEffort: null,
effort: null,
permissionMode: undefined,
collaborationMode: null,
agentState: null,
todos: null,
todosUpdatedAt: null,
teamState: null,
teamStateUpdatedAt: null
} as unknown as Session

const out = await migrator.migrateOne(session, {})
expect(out.ok).toBe(true)
if (!out.ok) return
expect(out.acpSessionId).toBe(cursorSessionId)
expect(out.sourceRemoved).toBe(true)
expect(existsSync(join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId, 'store.db'))).toBe(true)
expect(existsSync(sourceStore)).toBe(false)
expect(updateCalls).toHaveLength(1)
expect(updateCalls[0].lastUsedModel).toBe('composer-2.5')
}, 180_000)

it('migrates a REAL operator-supplied legacy store (LEGACY_FODDER_WSH + LEGACY_FODDER_UUID)', async () => {
const fodderWsh = process.env.LEGACY_FODDER_WSH
const fodderUuid = process.env.LEGACY_FODDER_UUID
if (!fodderWsh || !fodderUuid) {
// Skip silently; fodder is operator-local data we can't ship.
return
}
const realSourceStore = join(homedir(), '.cursor', 'chats', fodderWsh, fodderUuid, 'store.db')
if (!existsSync(realSourceStore)) {
throw new Error(`LEGACY_FODDER_WSH/UUID set but ${realSourceStore} does not exist`)
}
// Copy into fake HOME — operator's real store is NEVER touched.
const fakeSourceDir = join(fakeHome, '.cursor', 'chats', fodderWsh, fodderUuid)
mkdirSync(fakeSourceDir, { recursive: true })
copyFileSync(realSourceStore, join(fakeSourceDir, 'store.db'))

const updateCalls: Array<{ sessionId: string; namespace: string; lastUsedModel: string | null }> = []
const migrator = new CursorLegacyMigrator(
{ verifyTimeoutMs: 180_000 },
{
homeDir: () => fakeHome,
hostName: () => "integration",
tmpDir: () => tmp,
now: () => Date.now(),
createProbe: (env) => new AcpVerifyProbe({ env, timeoutMs: 120_000, hapiHome: tmp, skipLockAcquire: true }),
awaitLockRelease: async () => true,
isAgentAcpTransportActive: () => ({ active: false, holderPid: null }),
acquireAcpActiveLock: () => tryAcquireAcpActiveLock(tmp),
archiveSession: async () => {},
updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => {
updateCalls.push({ sessionId, namespace, lastUsedModel })
return { ok: true }
}
}
)
const session: Session = {
id: 'fodder-sess',
tag: 'fodder-sess',
namespace: 'default',
createdAt: 0,
updatedAt: 0,
seq: 0,
metadataVersion: 1,
agentStateVersion: 1,
metadata: {
path: tmpdir(),
host: 'integration',
flavor: 'cursor',
cursorSessionId: fodderUuid
} as Metadata,
active: false,
model: null,
modelReasoningEffort: null,
effort: null,
permissionMode: undefined,
collaborationMode: null,
agentState: null,
todos: null,
todosUpdatedAt: null,
teamState: null,
teamStateUpdatedAt: null
} as unknown as Session

const out = await migrator.migrateOne(session, { skipVerify: true })
// skipVerify because real fodder may have policies (e.g. ask permission, model unavailability) that fail a fresh prompt. The transplant + flip is the regression-critical path.
expect(out.ok).toBe(true)
if (!out.ok) return
expect(out.acpSessionId).toBe(fodderUuid)
expect(out.sourceRemoved).toBe(true)
expect(existsSync(join(fakeHome, '.cursor', 'acp-sessions', fodderUuid, 'store.db'))).toBe(true)
// Operator's real store ON DISK is unaffected because we operated only against fakeHome.
expect(existsSync(realSourceStore)).toBe(true)
expect(updateCalls).toHaveLength(1)
}, 240_000)

it('refuses to migrate when target collision exists', async () => {
const cursorSessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const wsh = 'wsh-collide'
const sourceDir = join(fakeHome, '.cursor', 'chats', wsh, cursorSessionId)
mkdirSync(sourceDir, { recursive: true })
buildSyntheticLegacyStore({ path: join(sourceDir, 'store.db') })
// Pre-existing ACP target.
mkdirSync(join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId), { recursive: true })
writeFileSync(join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId, 'meta.json'), '{}')

const migrator = new CursorLegacyMigrator({}, {
homeDir: () => fakeHome,
hostName: () => "integration",
tmpDir: () => tmp,
now: () => Date.now(),
createProbe: (env) => new AcpVerifyProbe({ env, hapiHome: tmp, skipLockAcquire: true }),
awaitLockRelease: async () => true,
isAgentAcpTransportActive: () => ({ active: false, holderPid: null }),
acquireAcpActiveLock: () => tryAcquireAcpActiveLock(tmp),
archiveSession: async () => {},
updateSessionAfterMigrate: () => ({ ok: true })
})
const session: Session = {
id: 'integration-collide',
tag: 'integration-collide',
namespace: 'default',
createdAt: 0,
updatedAt: 0,
seq: 0,
metadataVersion: 1,
agentStateVersion: 1,
metadata: {
path: tmpdir(),
host: 'integration',
flavor: 'cursor',
cursorSessionId
} as Metadata,
active: false,
model: null,
modelReasoningEffort: null,
effort: null,
permissionMode: undefined,
collaborationMode: null,
agentState: null,
todos: null,
todosUpdatedAt: null,
teamState: null,
teamStateUpdatedAt: null
} as unknown as Session
const out = await migrator.migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('target_already_exists')
})
})
68 changes: 68 additions & 0 deletions hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Build a synthetic legacy stream-json store.db for tests.
*
* The real cursor-agent legacy store has the same schema as the ACP one:
*
* CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB);
* CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);
*
* The migrator only ever reads the meta record (for lastUsedModel + name).
* Tests that drive the migrator against a synthetic store can use this
* builder to create a sufficiently realistic file without paying token
* cost or depending on a real cursor-agent install.
*
* NOT a public hub export - used only from hub/src/cursor/*.test.ts.
*/

import { Database } from 'bun:sqlite'
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'

export interface BuildSyntheticStoreOpts {
/** Absolute file path to write store.db to. Parent dirs created automatically. */
path: string
/** Free-form session name shown by the IDE; mirrors meta.name. */
name?: string
/** lastUsedModel hint (legacy stream-json or ACP wireid; both valid). */
lastUsedModel?: string
/** agentId; arbitrary string (cursor-agent doesn't validate it). */
agentId?: string
/** ISO timestamp; defaults to now. */
createdAt?: string
/**
* Whether to store meta value as hex-encoded UTF-8 JSON (older cursor-agent
* versions) or as raw JSON text (newer versions). Defaults to hex which is
* what the on-disk fodder sessions in the spike were stored as.
*/
metaEncoding?: 'hex' | 'json'
}

export function buildSyntheticLegacyStore(opts: BuildSyntheticStoreOpts): void {
const { path } = opts
mkdirSync(dirname(path), { recursive: true })
// Pre-touch the file so bun:sqlite definitely creates a fresh DB instead
// of opening anything pre-existing.
writeFileSync(path, '')
const db = new Database(path, { create: true, readwrite: true })
try {
db.exec('CREATE TABLE IF NOT EXISTS blobs (id TEXT PRIMARY KEY, data BLOB)')
db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)')
const metaPayload: Record<string, unknown> = {
agentId: opts.agentId ?? 'synthetic-agent',
latestRootBlobId: 'synthetic-root',
name: opts.name ?? 'synthetic legacy chat',
mode: 'agent',
createdAt: opts.createdAt ?? new Date().toISOString()
}
if (opts.lastUsedModel) {
metaPayload.lastUsedModel = opts.lastUsedModel
}
const json = JSON.stringify(metaPayload)
const encoded = (opts.metaEncoding ?? 'hex') === 'hex'
? Buffer.from(json, 'utf8').toString('hex')
: json
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('record', encoded)
} finally {
db.close()
}
}
17 changes: 13 additions & 4 deletions hub/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const REQUIRED_TABLES = [

export class Store {
private db: Database
private readonly dbPath: string
private readonly _dbPath: string
private closed: boolean = false

readonly sessions: SessionStore
Expand All @@ -43,8 +43,17 @@ export class Store {
readonly users: UserStore
readonly push: PushStore

/**
* Filesystem path of the underlying SQLite database, or ':memory:' for
* in-memory stores. Used by the legacy → ACP migrator (#824) to take a
* backup before a bulk run; treat as read-only.
*/
get dbPath(): string {
return this._dbPath
}

constructor(dbPath: string) {
this.dbPath = dbPath
this._dbPath = dbPath
if (dbPath !== ':memory:' && !dbPath.startsWith('file::memory:')) {
const dir = dirname(dbPath)
mkdirSync(dir, { recursive: true, mode: 0o700 })
Expand Down Expand Up @@ -464,9 +473,9 @@ export class Store {
}

private buildSchemaMismatchError(currentVersion: number): Error {
const location = (this.dbPath === ':memory:' || this.dbPath.startsWith('file::memory:'))
const location = (this._dbPath === ':memory:' || this._dbPath.startsWith('file::memory:'))
? 'in-memory database'
: this.dbPath
: this._dbPath
return new Error(
`SQLite schema version mismatch for ${location}. ` +
`Expected ${SCHEMA_VERSION}, found ${currentVersion}. ` +
Expand Down
Loading
Loading