Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c096137
feat(cursor): operator-driven legacy stream-json -> ACP session migra…
heavygee Jun 6, 2026
2e3d0a1
fix(cursor): tighten CursorMigrateOutcome dryRun discriminator for st…
heavygee Jun 6, 2026
a731c1e
fix(cursor): only rmdir empty parent on legacy source cleanup
heavygee Jun 6, 2026
2d2c305
fix(cursor): address Codex review #34 findings (P1 WAL, P2 skipVerify…
heavygee Jun 6, 2026
2cbe6ed
fix(cursor): address second round of Codex review #34 findings (5 iss…
heavygee Jun 6, 2026
96578a5
fix(cursor): third round of Codex review #34 findings (5 more)
heavygee Jun 6, 2026
eb446b3
fix(cursor): fourth round of Codex review #34 findings (3 more)
heavygee Jun 6, 2026
3d29a71
ci: nudge codex bot to re-review HEAD eb446b39
heavygee Jun 6, 2026
be7696a
fix(cursor): fifth round of Codex review #34 findings (5 more)
heavygee Jun 6, 2026
a6908df
fix(cursor): sixth round of Codex review #34 findings (2 more)
heavygee Jun 6, 2026
8145b73
fix(cursor): seventh round of Codex review #34 findings (3 more)
heavygee Jun 7, 2026
6bc2b78
fix(cursor): eighth round of Codex review #34 findings (3 more)
heavygee Jun 7, 2026
06cb2e6
fix(cursor): ninth round of Codex review #34 findings (3 more)
heavygee Jun 7, 2026
a9cfa41
ci: nudge codex bot to re-review HEAD 06cb2e6b (round 10)
heavygee Jun 7, 2026
9e2cd5e
fix(cursor): tenth round of Codex review #34 findings (4 more)
heavygee Jun 7, 2026
c3a984d
ci: nudge codex bot to re-review HEAD 9e2cd5e0 (round 11)
heavygee Jun 7, 2026
5010bd7
ci: poke codex re-review (round 11 v2) post quota refill
heavygee Jun 7, 2026
5899a00
fix(cursor): eleventh round of Codex review #34 findings (6 more)
heavygee Jun 7, 2026
fc7b2e1
ci: nudge codex re-review (round 12) on HEAD 5899a008
heavygee Jun 7, 2026
cb614b6
fix(cursor): twelfth round of Codex review #34 findings (2 more)
heavygee Jun 7, 2026
0058220
feat(cursor): pivot legacy->ACP migration to invisible sync-on-open
heavygee Jun 7, 2026
d26739a
chore(cursor): strip bulk migration surface per #824 reviewer feedback
heavygee Jun 7, 2026
2ba0867
fix(cursor): drop ACP lock guard changes; coordinate with swear01 #835
heavygee Jun 7, 2026
3cc4c03
fix(cursor): add observability to maybeAutoMigrateLegacyCursorSession
heavygee Jun 7, 2026
5d61dcf
fix(cursor): augment PATH with ~/.local/bin in AcpVerifyProbe spawn
heavygee Jun 7, 2026
6c682fa
fix(cursor): resolve agent binary against process.env.HOME, not baseE…
heavygee Jun 7, 2026
5c13a2b
fix(cursor): address Codex review #34 round-13 findings (5 P2s)
heavygee Jun 7, 2026
e6e8b9c
fix(cursor): create ACP session targets with private permissions
heavygee Jun 7, 2026
35daa9f
feat(cursor): A++ migration banner ships with auto-migrator
heavygee Jun 7, 2026
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,047 changes: 1,047 additions & 0 deletions hub/src/cursor/cursorLegacyMigrator.test.ts

Large diffs are not rendered by default.

1,033 changes: 1,033 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