Files
hapi/hub/src/cursor/cursorLegacyMigrator.test.ts
T
55d1bbb7bd feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP (#844)
* feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP

Closes #824

When the operator reopens a legacy stream-json Cursor session in HAPI,
the hub now transparently transplants its `~/.cursor/chats/<wsh>/<uuid>/store.db`
into `~/.cursor/acp-sessions/<uuid>/`, verifies it loads via `agent acp`,
flips `metadata.cursorSessionProtocol = 'acp'`, and removes the legacy
source - all before `resumeSession` returns. Subsequent opens are pure ACP.

The primary justification is safety, not feature parity. #784 (`cursor-agent`
fabricates `Questions skipped by the user` responses in legacy stream-json
mode) still fires regularly in dogfood despite #801's mitigation: the agent
ships destructive side effects against fabricated consent. Migration to ACP
closes the protocol-level door because the `AskQuestion` tool does not exist
on the ACP side, so there is nothing to fabricate.

working. That tradeoff was reasonable at the time. The accumulated #784
evidence makes legacy sessions actively unsafe; this PR makes the upgrade
path invisible enough that users stop avoiding it.

A pre-PR spike established that legacy and ACP `store.db` files use the
identical SQLite schema; only the directory layout differs. The migrator
therefore:

1. Sanity-checks the source store and pre-flips state (`session.active`,
   `lifecycleState`, on-disk presence, target collision)
2. Optionally archives a stale-running row (`forceArchiveRunning: true` is
   the default for the auto-migrate path because the caller already
   verified `session.active === false`)
3. Atomically creates `~/.cursor/acp-sessions/<uuid>/` with mode `0o700`
4. Copies `store.db` and chmods to `0o600` (multi-user-host hardening)
5. Writes a minimal `meta.json` sidecar (`schemaVersion`, `cwd`, optional
   `title`) with mode `0o600`
6. Spawns `agent acp` under HAPI_HOME isolation and verifies the session
   loads via `session/load`. On long histories the verify also drives a
   trivial single-turn prompt; on short ones load-only is enough
7. Flips `cursorSessionProtocol = 'acp'` AND clears the
   `cursorMigrationState` banner flag in a SINGLE metadata write
8. Removes the legacy source store (only after verify succeeded and the
   protocol flip committed). The legacy `~/.cursor/chats` parent dir is
   left as-is

Every failure leaves the legacy state intact. No `rm` fires without a
verify success AND a committed protocol flip.

The transplant takes 15-20s on long histories (copy a multi-hundred-MB
store, spawn `agent acp`, replay thousands of notifications, tear down
the probe). Without a progress indicator the wait reads as "broken" to a
fresh reviewer. A minimal banner ships alongside the migrator:

- Hub sets `metadata.cursorMigrationState = 'in_progress'` BEFORE the
  long-running transplant. The session-cache refresh emits the existing
  `session-updated` SSE event (no new event type), so the web client
  picks it up in milliseconds. No client-side polling needed.
- Hub clears the flag in the SAME metadata write that flips
  `cursorSessionProtocol` to `'acp'` on success, so the banner disappears
  in the same render tick the chat re-renders as ACP - no flicker window.
- Hub clears the flag explicitly in the auto-migrate helper's `finally`
  on failure/exception, so the banner never gets stuck if migration
  falls back to the legacy launcher.
- Web renders an accessible (role=status, aria-live=polite) banner with
  an indeterminate spinner. Deliberately no fake percentage - we do not
  have phase data and a fake progress bar would lie.

This PR is intentionally sequenced AFTER swear01's three ACP mop-up PRs
(merged today as ad038bbf, 8094b500, fa363c2f), all of which are
prerequisite for safe concurrent ACP launches.

The verify probe spawns `agent acp` directly via `AcpVerifyProbe` under
HAPI_HOME isolation (the migrator overrides `HOME` to a temp dir for the
verify pass), so it never touches `<real-HAPI_HOME>/locks/agent-acp-active/`
at all. Per swear01's #835 design note, the post-flip ACP launcher claims
the lock through the standard `registerActiveAcpTransport` entry and
behaves like any other concurrent ACP start. The migrator itself never
writes `pid` or `count` files directly.

The auto-migrate path is gated by `HAPI_CURSOR_LEGACY_AUTO_MIGRATE`. Set
to `0`, `false`, `no`, or `off` to suppress it entirely (legacy sessions
keep running through the existing stream-json launcher). Default is on.

A REST endpoint at `POST /api/sessions/:id/migrate-to-acp` allows
explicit migration of a single session outside the sync-on-open path
(e.g. for a specific cold archived session a user wants to re-engage).
Bulk migration surfaces (CLI subcommand, web button, bulk REST endpoint,
candidate-listing API) were deliberately stripped per reviewer feedback;
per-session sync-on-open + this escape hatch are the only two paths.

- 343 hub unit tests (4 new for the migration banner flag transitions,
  53 for the migrator core, 32 for the verify probe, 15 for the auto-
  migrate helper guard matrix, plus existing suites)
- 3 integration tests against a real `agent acp` (skipped by default
  unless `HAPI_CURSOR_LEGACY_MIGRATOR_INTEGRATION=1`)
- 10 web unit tests for the banner component (visibility paths + a11y)
- Typecheck clean across cli, web, hub

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): address upstream Codex review findings on #844 (2 majors)

Finding 1 (Major) — verify probe `agentLookupHome` ignored `metadata.homeDir`
in service-account hub deployments. `migrateOne` resolved the legacy store
under `metadata.homeDir` (the recorded session-owner home) but the default
createProbe factory still set `agentLookupHome` from `this.deps.homeDir()`
(the hub user's home). On a service-account hub, the store lookup
succeeded but `agent acp` discovery fell back to the hub user's
`~/.local/bin`, so verify silently failed and sync-on-open quietly fell
back to legacy.

Fix:
- Widen `CursorLegacyMigratorDeps.createProbe` signature from
  `(env) => AcpVerifyProbe` to `(env, agentLookupHome) => AcpVerifyProbe`
- Default factory uses the passed `agentLookupHome`
- `verifyInTempHome` threads `opts.sourceHome` (already the resolved
  session-owner home) through as the 2nd arg

2 new regression tests pin the contract:
- service-account case (metadata.homeDir != deps.homeDir()): captured
  agentLookupHome MUST equal metadata.homeDir
- legacy session record (no metadata.homeDir): falls back to
  deps.homeDir() correctly

Finding 2 (Major) — `bun.lock` win32-x64 pinned to 0.20.0 while
`cli/package.json` required 0.20.1 (rebase artifact from the v0.20.0 →
v0.20.1 release commit landing in upstream/main between the original
spike and the rebase). Frozen-install Windows users would either get
the wrong native binary or have the lock rejected.

Fix: regenerated bun.lock so the entry resolves
`@twsxtd/hapi-win32-x64@0.20.1`. `bun install --frozen-lockfile` now
passes clean.

Test budget:
- 391 hub unit tests pass (2 new for createProbe agentLookupHome
  contract, +0 regressions)
- Typecheck clean across cli + web + hub
- `bun install --frozen-lockfile` clean

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:57:55 +08:00

1140 lines
54 KiB
TypeScript

/**
* Unit tests for the legacy stream-json → ACP migrator (tiann/hapi#824).
*
* Strategy:
* - Real filesystem in a per-test tmpdir (cheaper than mocking node:fs)
* - Real bun:sqlite for the synthetic store fixture (the migrator reads
* meta.lastUsedModel directly)
* - MOCK agent acp via the createProbe dependency injection point. The
* mock probe records calls and returns scripted responses so we can
* exercise every branch without spawning a child process.
* - MOCK the hapi.db write via the updateSessionAfterMigrate dep.
*
* Covers:
* - happy path: cp + verify + flip + rm
* - --keep-source preserves the source after success
* - lastUsedModel round-trip
* - refusals: not a cursor session, already on ACP, no cursor session id,
* missing on-disk store, ACP target already exists, running without
* force-archive
* - rollback on session/load failure
* - rollback on session/prompt failure
* - rollback on metadata write failure
* - --force-archive-then-migrate archives a running session before proceeding
* - skipVerify path (load + prompt both skipped, no probe spawned)
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Metadata } from '@hapi/protocol/schemas'
import type { Session } from '@hapi/protocol/types'
import type { AcpRpcResponse } from './acpVerifyProbe'
import { CursorLegacyMigrator, findLegacyChatStore, readLegacyMetaLastUsedModel } from './cursorLegacyMigrator'
import { buildSyntheticLegacyStore } from './fixtures/buildSyntheticLegacyStore'
/* ---------- mock probe ---------- */
interface ScriptedProbe {
initializeResponse: AcpRpcResponse
loadResponse: AcpRpcResponse
loadNotificationCount: number
promptResponse: AcpRpcResponse
started: boolean
stopped: boolean
initializeCalls: number
loadCalls: number
promptCalls: number
}
function ok(result: Record<string, unknown> = {}): AcpRpcResponse {
return { ok: true, result }
}
function err(message: string, code: number = -32602): AcpRpcResponse {
return { ok: false, error: { code, message } }
}
function makeMockProbe(overrides: Partial<ScriptedProbe> = {}): ScriptedProbe & {
start(): void
stop(): Promise<void>
initialize(): Promise<AcpRpcResponse>
loadSession(): Promise<{ response: AcpRpcResponse; notificationCount: number; notificationKinds: Record<string, number>; durationMs: number }>
prompt(): Promise<{ response: AcpRpcResponse; durationMs: number }>
setModel?(): Promise<AcpRpcResponse>
} {
const state: ScriptedProbe = {
initializeResponse: ok({ protocolVersion: 1 }),
loadResponse: ok({ models: { availableModels: [], currentModelId: 'default[]' }, modes: { availableModes: [], currentModeId: 'agent' } }),
loadNotificationCount: 17,
promptResponse: ok({ stopReason: 'end_turn' }),
started: false,
stopped: false,
initializeCalls: 0,
loadCalls: 0,
promptCalls: 0,
...overrides
}
return {
...state,
start() { state.started = true },
async stop() { state.stopped = true },
async initialize() {
state.initializeCalls += 1
return state.initializeResponse
},
async loadSession() {
state.loadCalls += 1
return {
response: state.loadResponse,
notificationCount: state.loadResponse.ok ? state.loadNotificationCount : 0,
notificationKinds: {},
durationMs: 50
}
},
async prompt() {
state.promptCalls += 1
return { response: state.promptResponse, durationMs: 30 }
},
get started() { return state.started },
get stopped() { return state.stopped },
get initializeCalls() { return state.initializeCalls },
get loadCalls() { return state.loadCalls },
get promptCalls() { return state.promptCalls }
} as unknown as ScriptedProbe & ReturnType<typeof makeMockProbe>
}
/* ---------- test harness ---------- */
interface Harness {
home: string
tmp: string
chatsDir: string
acpSessionsDir: string
/** Build a fake legacy session on disk; returns the on-disk path of store.db */
placeLegacyStore: (cursorSessionId: string, opts?: { workspaceHash?: string; lastUsedModel?: string; name?: string }) => string
/** Make an in-memory Session row in the cursor flavor */
makeSession: (overrides?: Partial<Session>) => Session
updateCalls: Array<{ sessionId: string; namespace: string; lastUsedModel: string | null }>
archiveCalls: string[]
probes: ReturnType<typeof makeMockProbe>[]
nextProbe: ReturnType<typeof makeMockProbe> | null
}
function makeHarness(): Harness {
const home = mkdtempSync(join(tmpdir(), 'hapi-migrator-test-home-'))
const tmp = mkdtempSync(join(tmpdir(), 'hapi-migrator-test-tmp-'))
const chatsDir = join(home, '.cursor', 'chats')
const acpSessionsDir = join(home, '.cursor', 'acp-sessions')
mkdirSync(chatsDir, { recursive: true })
mkdirSync(acpSessionsDir, { recursive: true })
const updateCalls: Harness['updateCalls'] = []
const archiveCalls: Harness['archiveCalls'] = []
const probes: Harness['probes'] = []
return {
home,
tmp,
chatsDir,
acpSessionsDir,
updateCalls,
archiveCalls,
probes,
nextProbe: null,
placeLegacyStore(cursorSessionId, opts = {}) {
const wsh = opts.workspaceHash ?? `wsh-${Math.random().toString(36).slice(2, 10)}`
const dir = join(chatsDir, wsh, cursorSessionId)
mkdirSync(dir, { recursive: true })
const storePath = join(dir, 'store.db')
buildSyntheticLegacyStore({
path: storePath,
name: opts.name,
lastUsedModel: opts.lastUsedModel
})
return storePath
},
makeSession(overrides = {}) {
const sessionId = overrides.id ?? `sess-${Math.random().toString(36).slice(2, 8)}`
const cursorSessionId = (overrides.metadata as Metadata | undefined)?.cursorSessionId
?? `cursor-${Math.random().toString(36).slice(2, 8)}`
const metadata: Metadata = {
path: '/workspace/example',
host: 'test-host',
flavor: 'cursor',
cursorSessionId,
...(overrides.metadata ?? {})
}
const base: Session = {
id: sessionId,
tag: sessionId,
namespace: 'default',
createdAt: 0,
updatedAt: 0,
seq: 0,
metadataVersion: 1,
agentStateVersion: 1,
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
return { ...base, ...overrides, metadata }
}
}
}
function cleanupHarness(h: Harness): void {
try { rmSync(h.home, { recursive: true, force: true }) } catch {}
try { rmSync(h.tmp, { recursive: true, force: true }) } catch {}
}
function makeMigrator(h: Harness, probe: ReturnType<typeof makeMockProbe> | null, opts: { archiveSession?: (id: string) => Promise<void>; updateOverride?: (sessionId: string, namespace: string, lastUsedModel: string | null) => { ok: true } | { ok: false; reason: 'version_mismatch_or_missing' } | { ok: false; reason: 'session_active' }; isAgentAcpTransportActive?: () => { active: boolean; holderPid: number | null }; getCurrentSession?: (sessionId: string, namespace: string) => { active: boolean; lifecycleState?: string; cursorSessionProtocol?: string } | null; acquireAcpActiveLock?: () => { release(): void } | null; checkpointLegacyStore?: (storeDbPath: string) => void } = {}): CursorLegacyMigrator {
return new CursorLegacyMigrator({}, {
homeDir: () => h.home,
hostName: () => 'h', // matches the test sessions' metadata.host
tmpDir: () => h.tmp,
now: () => 1_700_000_000_000,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createProbe: () => (probe ?? makeMockProbe()) as any,
awaitLockRelease: async () => true,
// Default to "no ACP transport active" so the migrator does not
// accidentally refuse on a real-world HAPI_HOME that happens to have
// a live lock during the test run (e.g. running on the operator's
// own machine while the dogfood agent is active).
isAgentAcpTransportActive: opts.isAgentAcpTransportActive ?? (() => ({ active: false, holderPid: null })),
// Default: acquire returns a no-op handle (we control the live
// lock check via isAgentAcpTransportActive instead). Tests
// simulating "lock unavailable" can return null here directly.
// Codex review #34 P2 v7.
acquireAcpActiveLock: opts.acquireAcpActiveLock ?? (() => ({ release() {} })),
// Default: no-op checkpoint. The real bun:sqlite checkpoint is
// exercised in integration tests; unit tests inject custom
// implementations to simulate post-checkpoint WAL growth.
// Codex review #34 P2 v8.
checkpointLegacyStore: opts.checkpointLegacyStore ?? (() => {}),
getCurrentSession: opts.getCurrentSession,
logger: { debug() {}, info() {}, warn() {}, error() {} },
archiveSession: opts.archiveSession ?? (async (id) => { h.archiveCalls.push(id) }),
updateSessionAfterMigrate: opts.updateOverride ?? ((sessionId, namespace, lastUsedModel) => {
h.updateCalls.push({ sessionId, namespace, lastUsedModel })
return { ok: true }
})
})
}
/* ---------- tests ---------- */
describe('findLegacyChatStore', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
afterEach(() => cleanupHarness(h))
it('finds the store.db under ~/.cursor/chats/<wsh>/<uuid>/', () => {
const storePath = h.placeLegacyStore('my-uuid', { workspaceHash: 'wsh-1' })
const found = findLegacyChatStore('my-uuid', h.home)
expect(found).not.toBeNull()
expect(found?.storeDbPath).toBe(storePath)
expect(found?.workspaceHash).toBe('wsh-1')
})
it('returns null when the chat does not exist on disk', () => {
const found = findLegacyChatStore('non-existent-uuid', h.home)
expect(found).toBeNull()
})
it('returns null when ~/.cursor/chats itself does not exist', () => {
rmSync(join(h.home, '.cursor'), { recursive: true, force: true })
const found = findLegacyChatStore('whatever', h.home)
expect(found).toBeNull()
})
it('scans multiple workspace-hash dirs to find the matching uuid', () => {
h.placeLegacyStore('uuid-a', { workspaceHash: 'wsh-a' })
h.placeLegacyStore('uuid-b', { workspaceHash: 'wsh-b' })
const found = findLegacyChatStore('uuid-b', h.home)
expect(found?.workspaceHash).toBe('wsh-b')
})
})
describe('readLegacyMetaLastUsedModel', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
afterEach(() => cleanupHarness(h))
it('reads hex-encoded JSON meta record (legacy encoding)', () => {
const p = join(h.tmp, 'legacy.db')
buildSyntheticLegacyStore({ path: p, lastUsedModel: 'composer-2.5', name: 'chat 1', metaEncoding: 'hex' })
const out = readLegacyMetaLastUsedModel(p)
expect(out).not.toBeNull()
expect(out?.lastUsedModel).toBe('composer-2.5')
expect(out?.name).toBe('chat 1')
})
it('reads raw JSON meta record (newer encoding)', () => {
const p = join(h.tmp, 'newer.db')
buildSyntheticLegacyStore({ path: p, lastUsedModel: 'gpt-5.3-codex', metaEncoding: 'json' })
const out = readLegacyMetaLastUsedModel(p)
expect(out?.lastUsedModel).toBe('gpt-5.3-codex')
})
it('returns null on a missing store', () => {
const out = readLegacyMetaLastUsedModel(join(h.tmp, 'does-not-exist.db'))
expect(out).toBeNull()
})
})
describe('CursorLegacyMigrator.migrateOne — refusals', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
afterEach(() => cleanupHarness(h))
it('refuses non-cursor sessions', async () => {
const session = h.makeSession({ metadata: { path: '/x', host: 'h', flavor: 'claude' } as Metadata })
const out = await makeMigrator(h, null).migrateOne(session, {})
expect(out.ok).toBe(false)
if (!out.ok) expect(out.reason).toBe('not_cursor_session')
})
it('refuses already-ACP sessions', async () => {
const session = h.makeSession({
metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'u', cursorSessionProtocol: 'acp' }
})
const out = await makeMigrator(h, null).migrateOne(session, {})
expect(out.ok).toBe(false)
if (!out.ok) expect(out.reason).toBe('already_acp')
})
it('refuses sessions with no cursorSessionId', async () => {
const session = h.makeSession({
metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: undefined as unknown as string }
})
const out = await makeMigrator(h, null).migrateOne(session, {})
expect(out.ok).toBe(false)
if (!out.ok) expect(out.reason).toBe('no_cursor_session_id')
})
it('refuses sessions whose lifecycleState is "running" without forceArchiveRunning', async () => {
const session = h.makeSession({
metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'u', lifecycleState: 'running' }
})
const out = await makeMigrator(h, null).migrateOne(session, {})
expect(out.ok).toBe(false)
if (!out.ok) expect(out.reason).toBe('running_refused')
})
it('refuses sessions where session.active=true even without lifecycleState (Codex #34 P2)', async () => {
const session = h.makeSession({
active: true,
metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'u' }
})
const out = await makeMigrator(h, null).migrateOne(session, {})
expect(out.ok).toBe(false)
if (!out.ok) expect(out.reason).toBe('running_refused')
})
it('refuses cursorSessionId values that fail basename validation (Codex #34 P2)', async () => {
for (const bad of ['../escape', 'has/slash', '.', '..', 'has\\backslash']) {
const session = h.makeSession({
metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: bad }
})
const out = await makeMigrator(h, null).migrateOne(session, {})
expect(out.ok).toBe(false)
if (!out.ok) expect(out.reason).toBe('no_cursor_session_id')
}
})
it('refuses sessions recorded on a different host (Codex #34 P2)', async () => {
const session = h.makeSession({
metadata: { path: '/x', host: 'other-machine', flavor: 'cursor', cursorSessionId: 'a' }
})
const out = await makeMigrator(h, null).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('cross_host_session')
expect(out.message).toContain('other-machine')
})
it('refuses when the legacy on-disk store is missing', async () => {
const session = h.makeSession({
metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'ghost-uuid' }
})
const out = await makeMigrator(h, null).migrateOne(session, {})
expect(out.ok).toBe(false)
if (!out.ok) expect(out.reason).toBe('no_legacy_store_on_disk')
})
it('refuses when another agent acp transport is live (Codex #34 P1 / P2 v7)', async () => {
const cursorSessionId = 'acp-active-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
// Codex review #34 P2 v7: lock acquisition is the primary
// refuse-on signal now. isAgentAcpTransportActive is read
// only to format the holder pid in the refusal message.
acquireAcpActiveLock: () => null,
isAgentAcpTransportActive: () => ({ active: true, holderPid: 12345 })
}).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('acp_transport_active')
expect(out.message).toContain('12345')
// Source untouched.
expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false)
})
it('refuses when ~/.cursor/acp-sessions/<uuid>/ already exists (collision)', async () => {
const cursorSessionId = 'collision-uuid'
h.placeLegacyStore(cursorSessionId)
mkdirSync(join(h.acpSessionsDir, cursorSessionId), { recursive: true })
writeFileSync(join(h.acpSessionsDir, cursorSessionId, 'meta.json'), '{}')
const session = h.makeSession({
metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, null).migrateOne(session, {})
expect(out.ok).toBe(false)
if (!out.ok) expect(out.reason).toBe('target_already_exists')
})
})
describe('CursorLegacyMigrator.migrateOne — happy path', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
afterEach(() => cleanupHarness(h))
it('cp + verify + flip + rm in order; populates outcome', async () => {
const cursorSessionId = 'happy-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId, { lastUsedModel: 'composer-2.5' })
const probe = makeMockProbe()
const session = h.makeSession({
id: 'happy-sess',
metadata: { path: '/workspace/example', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, probe).migrateOne(session, {})
expect(out.ok).toBe(true)
if (!out.ok) return
expect(out.acpSessionId).toBe(cursorSessionId)
expect(out.replayNotifications).toBe(17)
expect(out.lastUsedModelPreserved).toBe('composer-2.5')
expect(out.sourceRemoved).toBe(true)
// ACP location populated.
const acpStorePath = join(h.acpSessionsDir, cursorSessionId, 'store.db')
expect(existsSync(acpStorePath)).toBe(true)
const sidecarPath = join(h.acpSessionsDir, cursorSessionId, 'meta.json')
expect(existsSync(sidecarPath)).toBe(true)
const sidecarText = require('node:fs').readFileSync(sidecarPath, 'utf8') as string
const sidecarObj = JSON.parse(sidecarText) as Record<string, unknown>
expect(sidecarObj.schemaVersion).toBe(1)
expect(sidecarObj.cwd).toBe('/workspace/example')
// Legacy source removed.
expect(existsSync(sourceStore)).toBe(false)
// updateSessionAfterMigrate invoked.
expect(h.updateCalls).toHaveLength(1)
expect(h.updateCalls[0].sessionId).toBe('happy-sess')
expect(h.updateCalls[0].lastUsedModel).toBe('composer-2.5')
// Probe used.
expect(probe.started).toBe(true)
expect(probe.stopped).toBe(true)
expect(probe.initializeCalls).toBe(1)
expect(probe.loadCalls).toBe(1)
expect(probe.promptCalls).toBe(1)
})
it('passes HOME and HAPI_HOME isolated to the fakeHome into the verify probe (tiann/hapi#824)', async () => {
// tiann/hapi#824: the verify probe must inherit a private HAPI_HOME
// so its child `agent acp` registers its lock in an isolated tmp dir
// — NOT in the host's $HAPI_HOME where peer agents may hold the
// global agent-acp-active lock. Without this isolation the auto-
// migration path on a busy machine would always refuse with
// acp_transport_active, defeating its own purpose.
const cursorSessionId = 'isolated-home-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/iso', host: 'h', flavor: 'cursor', cursorSessionId }
})
let capturedEnv: NodeJS.ProcessEnv | null = null
const migrator = new CursorLegacyMigrator({}, {
homeDir: () => h.home,
hostName: () => 'h',
tmpDir: () => h.tmp,
now: () => 1_700_000_000_000,
createProbe: (env) => {
capturedEnv = env
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return makeMockProbe() as any
},
awaitLockRelease: async () => true,
isAgentAcpTransportActive: () => ({ active: false, holderPid: null }),
acquireAcpActiveLock: () => ({ release() {} }),
checkpointLegacyStore: () => {},
getCurrentSession: () => null,
logger: { debug() {}, info() {}, warn() {}, error() {} },
archiveSession: async (id) => { h.archiveCalls.push(id) },
updateSessionAfterMigrate: () => ({ ok: true })
})
const out = await migrator.migrateOne(session, {})
expect(out.ok).toBe(true)
expect(capturedEnv).not.toBeNull()
const env = capturedEnv as unknown as NodeJS.ProcessEnv
// The fakeHome path is generated inside verifyInTempHome under
// h.tmp; we don't need its exact value, only that HOME and
// HAPI_HOME point at the same path and that path is under our
// temp root (i.e. NOT the operator's real $HOME or $HAPI_HOME).
expect(typeof env.HOME).toBe('string')
expect(typeof env.HAPI_HOME).toBe('string')
expect(env.HOME).toBe(env.HAPI_HOME)
expect(env.HOME!.startsWith(h.tmp)).toBe(true)
// Defence in depth: the captured env must NOT leak the host's
// real HAPI_HOME (which could point at ~/.hapi or /tmp/hapi).
const realHapiHome = process.env.HAPI_HOME?.trim() || ''
if (realHapiHome.length > 0) {
expect(env.HAPI_HOME).not.toBe(realHapiHome)
}
})
it('passes metadata.homeDir (NOT deps.homeDir) as agentLookupHome to the verify probe (tiann/hapi#844)', async () => {
// tiann/hapi#844 upstream Codex Major: the default createProbe factory
// used `this.deps.homeDir()` for `agentLookupHome`, which on service-
// account hub deployments resolves to the hub user's $HOME — but the
// legacy store lives under the human user's home (metadata.homeDir).
// Earlier rounds wired `agentLookupHome` into the factory default
// but never threaded the resolved sourceHome through, so verification
// silently looked up `agent` under the wrong home and migrations
// fell back to legacy. The fix: createProbe takes a 2nd arg, and
// verifyInTempHome passes opts.sourceHome through.
const userHome = mkdtempSync(join(tmpdir(), 'hapi-migrator-user-home-'))
try {
const hubHome = h.home // distinct from userHome
const cursorSessionId = 'service-account-uuid'
const userChatsDir = join(userHome, '.cursor', 'chats', 'wsh-svc', cursorSessionId)
mkdirSync(userChatsDir, { recursive: true })
const sourceStore = join(userChatsDir, 'store.db')
buildSyntheticLegacyStore({ path: sourceStore })
const userAcpDir = join(userHome, '.cursor', 'acp-sessions')
mkdirSync(userAcpDir, { recursive: true })
const session = h.makeSession({
metadata: {
path: '/workspace/svc',
host: 'h',
flavor: 'cursor',
cursorSessionId,
homeDir: userHome
}
})
let capturedAgentLookupHome: string | null = null
const migrator = new CursorLegacyMigrator({}, {
homeDir: () => hubHome,
hostName: () => 'h',
tmpDir: () => h.tmp,
now: () => 1_700_000_000_000,
createProbe: (_env, agentLookupHome) => {
capturedAgentLookupHome = agentLookupHome
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return makeMockProbe() as any
},
awaitLockRelease: async () => true,
isAgentAcpTransportActive: () => ({ active: false, holderPid: null }),
acquireAcpActiveLock: () => ({ release() {} }),
checkpointLegacyStore: () => {},
getCurrentSession: () => null,
logger: { debug() {}, info() {}, warn() {}, error() {} },
archiveSession: async (id) => { h.archiveCalls.push(id) },
updateSessionAfterMigrate: () => ({ ok: true })
})
const out = await migrator.migrateOne(session, {})
expect(out.ok).toBe(true)
expect(capturedAgentLookupHome as unknown as string).toBe(userHome)
expect(capturedAgentLookupHome as unknown as string).not.toBe(hubHome)
} finally {
try { rmSync(userHome, { recursive: true, force: true }) } catch {}
}
})
it('falls back to deps.homeDir() for agentLookupHome when metadata.homeDir is absent (legacy session records)', async () => {
// Older session records may lack metadata.homeDir (the field was added
// in a later CLI rev). For those, the migrator falls back to
// this.deps.homeDir() for both the store lookup AND agentLookupHome.
const cursorSessionId = 'no-metadata-home-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/legacy', host: 'h', flavor: 'cursor', cursorSessionId }
})
let capturedAgentLookupHome: string | null = null
const migrator = new CursorLegacyMigrator({}, {
homeDir: () => h.home,
hostName: () => 'h',
tmpDir: () => h.tmp,
now: () => 1_700_000_000_000,
createProbe: (_env, agentLookupHome) => {
capturedAgentLookupHome = agentLookupHome
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return makeMockProbe() as any
},
awaitLockRelease: async () => true,
isAgentAcpTransportActive: () => ({ active: false, holderPid: null }),
acquireAcpActiveLock: () => ({ release() {} }),
checkpointLegacyStore: () => {},
getCurrentSession: () => null,
logger: { debug() {}, info() {}, warn() {}, error() {} },
archiveSession: async (id) => { h.archiveCalls.push(id) },
updateSessionAfterMigrate: () => ({ ok: true })
})
const out = await migrator.migrateOne(session, {})
expect(out.ok).toBe(true)
expect(capturedAgentLookupHome as unknown as string).toBe(h.home)
})
it('--keep-source preserves the legacy source after success', async () => {
const cursorSessionId = 'keep-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, { keepSource: true })
expect(out.ok).toBe(true)
if (!out.ok) return
expect(out.sourceRemoved).toBe(false)
expect(existsSync(sourceStore)).toBe(true)
})
it('skipVerify skips ONLY the session/prompt step (load is still verified)', async () => {
const cursorSessionId = 'skipverify-uuid'
h.placeLegacyStore(cursorSessionId)
const probe = makeMockProbe()
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, probe).migrateOne(session, { skipVerify: true })
expect(out.ok).toBe(true)
// Codex review #34 P2: load must still run; only the prompt step is skipped.
expect(probe.started).toBe(true)
expect(probe.initializeCalls).toBe(1)
expect(probe.loadCalls).toBe(1)
expect(probe.promptCalls).toBe(0)
})
it('skipVerify still refuses on session/load failure', async () => {
const cursorSessionId = 'skipverify-loadfail-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const probe = makeMockProbe({ loadResponse: err('corrupted store') })
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, probe).migrateOne(session, { skipVerify: true })
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('verify_load_failed')
// Source untouched.
expect(existsSync(sourceStore)).toBe(true)
})
it('lastUsedModel = null when the legacy meta record does not carry one', async () => {
const cursorSessionId = 'no-model-uuid'
h.placeLegacyStore(cursorSessionId) // no lastUsedModel
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {})
expect(out.ok).toBe(true)
if (!out.ok) return
expect(out.lastUsedModelPreserved).toBeNull()
expect(h.updateCalls[0].lastUsedModel).toBeNull()
})
})
describe('CursorLegacyMigrator.migrateOne — rollback paths', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
afterEach(() => cleanupHarness(h))
it('rolls back the ACP placement when session/load fails', async () => {
const cursorSessionId = 'load-fail-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const probe = makeMockProbe({ loadResponse: err('Session not found') })
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, probe).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('verify_load_failed')
// Acp dir not created (verify is in temp HOME, so the real acp-sessions
// location was never touched).
const acpStorePath = join(h.acpSessionsDir, cursorSessionId, 'store.db')
expect(existsSync(acpStorePath)).toBe(false)
// Source untouched.
expect(existsSync(sourceStore)).toBe(true)
// No hapi.db write.
expect(h.updateCalls).toHaveLength(0)
})
it('rolls back when session/prompt fails', async () => {
const cursorSessionId = 'prompt-fail-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const probe = makeMockProbe({ promptResponse: err('agent acp died') })
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, probe).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('verify_prompt_failed')
expect(existsSync(sourceStore)).toBe(true)
expect(h.updateCalls).toHaveLength(0)
})
it('rolls back when the session is resumed mid-migration (Codex #34 P1)', async () => {
const cursorSessionId = 'resumed-mid-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
getCurrentSession: () => ({ active: true, lifecycleState: 'running' })
}).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('session_resumed_during_migrate')
// ACP placement rolled back.
expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false)
// Source untouched.
expect(existsSync(sourceStore)).toBe(true)
})
it('does NOT trip the resume-race recheck on stale lifecycleState=running after our own archive (Codex #34 P2 v5)', async () => {
// The force-archive flow archives synchronously (sets active=false)
// but the cleanup metadata write that flips lifecycleState to
// 'archived' may still be in-flight. The recheck must trust the
// active flag, not lifecycleState.
const cursorSessionId = 'stale-lifecycle-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
// Live runner at preflight (active=true), gets archived by us.
active: true,
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' }
})
const out = await makeMigrator(h, makeMockProbe(), {
archiveSession: async () => {},
// After our archive: active=false (set by archive), but
// lifecycleState is still 'running' because the metadata
// cleanup write hasn't flushed yet. This should NOT refuse.
getCurrentSession: () => ({ active: false, lifecycleState: 'running' })
}).migrateOne(session, { forceArchiveRunning: true })
expect(out.ok).toBe(true)
if (!out.ok) return
expect(h.updateCalls).toHaveLength(1)
})
it('still refuses the resume-race recheck when an EXTERNAL party set lifecycleState=running and wasActive=false (Codex #34 P2 v5)', async () => {
// If we did NOT archive and lifecycleState becomes running,
// someone else lifted the session back — that's a real race.
const cursorSessionId = 'external-resume-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
// Session NOT active/running at preflight — passes precheck.
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
getCurrentSession: () => ({ active: false, lifecycleState: 'running' })
}).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('session_resumed_during_migrate')
})
it('rolls back when the legacy store.db is touched during the migration window (Codex #34 P1 v3)', async () => {
const cursorSessionId = 'fingerprint-divergence-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
// Override createProbe to mutate the legacy source between
// checkpoint and the resume-race recheck.
const probe = {
...makeMockProbe(),
start() { /* no-op */ },
async loadSession() {
// Simulate a brief resume that wrote new turns to the
// legacy store after our checkpoint.
require('node:fs').appendFileSync(sourceStore, Buffer.from([0x00, 0x01, 0x02]))
return { response: { ok: true as const, result: {} }, notificationCount: 0, notificationKinds: {}, durationMs: 1 }
},
async initialize() { return { ok: true as const, result: {} } },
async prompt() { return { response: { ok: true as const, result: {} }, durationMs: 1 } },
async stop() {},
getStderr() { return '' },
getNotifications() { return [] },
clearNotifications() {}
} as unknown as ReturnType<typeof makeMockProbe>
const out = await makeMigrator(h, probe, {}).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('legacy_store_modified_during_migrate')
expect(out.message).toMatch(/store\.db/)
// ACP placement rolled back.
expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false)
// Source untouched.
expect(existsSync(sourceStore)).toBe(true)
})
it('refuses early when WAL has content immediately after the checkpoint (Codex #34 P2 v8)', async () => {
// Codex review #34 P2 v8: a TRUNCATE-mode wal_checkpoint zeros
// the WAL. If a writer lands between checkpoint return and the
// fingerprint capture, the WAL grows above zero and our baseline
// would be poisoned (we copy main-file-only). The migrator must
// refuse rather than accept the post-resume state.
const cursorSessionId = 'wal-grew-post-checkpoint-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
// Simulate a writer landing in the gap: the checkpoint
// "returned" but a WAL with content has appeared right
// after — exactly the race the bot flagged.
checkpointLegacyStore: (path) => {
require('node:fs').writeFileSync(`${path}-wal`, Buffer.from([0xff, 0xff, 0xff, 0xff]))
}
}).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('legacy_store_modified_during_migrate')
expect(out.message).toMatch(/between checkpoint and fingerprint/)
// No ACP placement, no source deletion.
expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false)
expect(existsSync(sourceStore)).toBe(true)
})
it('rolls back when a WAL sidecar appears during the migration window (Codex #34 P1 v4)', async () => {
const cursorSessionId = 'wal-divergence-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
// Override createProbe to CREATE a store.db-wal sidecar where
// none existed at fingerprint time. This is what a brief resume
// does: opens the store, writes a frame to WAL.
const probe = {
...makeMockProbe(),
start() {},
async loadSession() {
require('node:fs').writeFileSync(`${sourceStore}-wal`, Buffer.from([0xff, 0xff, 0xff]))
return { response: { ok: true as const, result: {} }, notificationCount: 0, notificationKinds: {}, durationMs: 1 }
},
async initialize() { return { ok: true as const, result: {} } },
async prompt() { return { response: { ok: true as const, result: {} }, durationMs: 1 } },
async stop() {},
getStderr() { return '' },
getNotifications() { return [] },
clearNotifications() {}
} as unknown as ReturnType<typeof makeMockProbe>
const out = await makeMigrator(h, probe, {}).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('legacy_store_modified_during_migrate')
expect(out.message).toMatch(/store\.db-wal/)
expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false)
})
it('rolls back when the atomic flip-time active check fires (Codex #34 P1 v2)', async () => {
// The migrator's earlier getCurrentSession recheck saw the session
// as inactive (default null), but the inner updateSessionAfterMigrate
// returns session_active — simulating the resume landing AFTER the
// recheck but inside the atomic flip.
const cursorSessionId = 'flip-time-active-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const migrator = makeMigrator(h, makeMockProbe(), {
updateOverride: () => ({ ok: false, reason: 'session_active' })
})
const out = await migrator.migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('session_resumed_during_migrate')
expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false)
expect(existsSync(sourceStore)).toBe(true)
})
it('rolls back when a concurrent migration already flipped protocol to acp (Codex #34 P1)', async () => {
const cursorSessionId = 'concurrent-flip-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
getCurrentSession: () => ({ active: false, cursorSessionProtocol: 'acp' })
}).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('already_acp')
expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false)
expect(existsSync(sourceStore)).toBe(true)
})
it('rolls back ACP placement when hapi.db metadata write fails', async () => {
const cursorSessionId = 'meta-fail-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const migrator = makeMigrator(h, makeMockProbe(), {
updateOverride: () => ({ ok: false, reason: 'version_mismatch_or_missing' })
})
const out = await migrator.migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('metadata_write_failed')
// The ACP placement was rolled back.
expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false)
// Source untouched.
expect(existsSync(sourceStore)).toBe(true)
})
})
describe('CursorLegacyMigrator.migrateOne — force-archive-then-migrate', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
afterEach(() => cleanupHarness(h))
it('archives a running session first, then proceeds', async () => {
const cursorSessionId = 'force-archive-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
// active=true triggers the archive RPC. Codex review #34
// P2 v6: the lifecycleState alone is no longer enough.
active: true,
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' }
})
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, { forceArchiveRunning: true })
expect(out.ok).toBe(true)
expect(h.archiveCalls).toEqual([session.id])
})
it('does NOT call archiveSession on stale lifecycleState=running rows with no live runner (Codex #34 P2 v6)', async () => {
const cursorSessionId = 'stale-running-no-archive-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
// lifecycle says running but cache.active is false — the
// cleanup metadata write that flips 'running' → 'archived'
// was dropped (process crash before write). There is no
// live runner to archive.
active: false,
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' }
})
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, { forceArchiveRunning: true })
expect(out.ok).toBe(true)
if (!out.ok) return
// Critically: we did NOT call archive RPC. The metadata flip
// itself cleans up the stale lifecycle value.
expect(h.archiveCalls).toHaveLength(0)
})
it('does not archive when cross_host_session refusal would fire (Codex #34 P2 v2)', async () => {
const cursorSessionId = 'cross-host-no-archive-uuid'
h.placeLegacyStore(cursorSessionId)
const archiveCalls: string[] = []
const session = h.makeSession({
// active=true so wasActive would normally trigger archive
// — proves the cross-host check correctly precedes it.
active: true,
metadata: { path: '/x', host: 'other-machine', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' }
})
const out = await makeMigrator(h, makeMockProbe(), {
archiveSession: async (id) => { archiveCalls.push(id) }
}).migrateOne(session, { forceArchiveRunning: true })
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('cross_host_session')
expect(archiveCalls).toHaveLength(0)
})
it('reserves and releases the ACP lock around the mutation window (Codex #34 P2 v7)', async () => {
const cursorSessionId = 'acp-lock-lifecycle-uuid'
h.placeLegacyStore(cursorSessionId)
let releaseCount = 0
let acquireCount = 0
const session = h.makeSession({
active: true,
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' }
})
const out = await makeMigrator(h, makeMockProbe(), {
acquireAcpActiveLock: () => {
acquireCount += 1
return { release() { releaseCount += 1 } }
}
}).migrateOne(session, { forceArchiveRunning: true })
expect(out.ok).toBe(true)
// Lock was acquired exactly once and released exactly once even
// on the happy path.
expect(acquireCount).toBe(1)
expect(releaseCount).toBe(1)
})
it('releases the ACP lock even when migration refuses inside the locked window (Codex #34 P2 v7)', async () => {
const cursorSessionId = 'acp-lock-on-refusal-uuid'
h.placeLegacyStore(cursorSessionId)
// Pre-create the ACP target dir so target_already_exists fires
// INSIDE migrateOneWithLock (lock has been acquired by then).
const { mkdirSync } = require('node:fs')
mkdirSync(join(h.acpSessionsDir, cursorSessionId), { recursive: true })
let releaseCount = 0
const session = h.makeSession({
metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
acquireAcpActiveLock: () => ({ release() { releaseCount += 1 } })
}).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('target_already_exists')
expect(releaseCount).toBe(1)
})
it('does not archive when acp_transport_active refusal would fire (Codex #34 P2 v2 / P2 v7)', async () => {
const cursorSessionId = 'acp-active-no-archive-uuid'
h.placeLegacyStore(cursorSessionId)
const archiveCalls: string[] = []
const session = h.makeSession({
// active=true so wasActive would normally trigger archive
// — proves the acp_transport_active check correctly precedes it.
active: true,
metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' }
})
const out = await makeMigrator(h, makeMockProbe(), {
// Codex review #34 P2 v7: lock acquisition is reserved BEFORE
// archive. Returning null here is the new way to express
// "another agent acp transport is live".
acquireAcpActiveLock: () => null,
isAgentAcpTransportActive: () => ({ active: true, holderPid: 42 }),
archiveSession: async (id) => { archiveCalls.push(id) }
}).migrateOne(session, { forceArchiveRunning: true })
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('acp_transport_active')
expect(archiveCalls).toHaveLength(0)
})
it('surfaces archive failures as archive_failed', async () => {
const cursorSessionId = 'archive-throws-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
// Live runner — wasActive=true triggers the archive RPC,
// which throws and we surface archive_failed. Codex #34 P2 v6.
active: true,
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' }
})
const migrator = makeMigrator(h, makeMockProbe(), {
archiveSession: async () => { throw new Error('rpc gateway down') }
})
const out = await migrator.migrateOne(session, { forceArchiveRunning: true })
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('archive_failed')
expect(out.message).toContain('rpc gateway down')
})
})
describe('CursorLegacyMigrator.migrateOne — telemetry', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
afterEach(() => cleanupHarness(h))
it('records a non-zero durationMs in every outcome', async () => {
const cursorSessionId = 'duration-uuid'
h.placeLegacyStore(cursorSessionId)
let t = 1_000_000
const migrator = new CursorLegacyMigrator({}, {
homeDir: () => h.home,
hostName: () => 'h',
tmpDir: () => h.tmp,
now: () => (t += 25),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createProbe: () => makeMockProbe() as any,
awaitLockRelease: async () => true,
isAgentAcpTransportActive: () => ({ active: false, holderPid: null }),
archiveSession: async () => {},
updateSessionAfterMigrate: () => ({ ok: true })
})
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await migrator.migrateOne(session, {})
expect(out.ok).toBe(true)
if (!out.ok) return
expect(out.durationMs).toBeGreaterThan(0)
})
it('synthesized meta.json title comes from legacy meta name when present', async () => {
const cursorSessionId = 'title-uuid'
h.placeLegacyStore(cursorSessionId, { name: 'My Test Chat' })
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {})
expect(out.ok).toBe(true)
const sidecarPath = join(h.acpSessionsDir, cursorSessionId, 'meta.json')
const sidecar = JSON.parse(require('node:fs').readFileSync(sidecarPath, 'utf8')) as Record<string, unknown>
expect(sidecar.title).toBe('My Test Chat')
})
it('uses metadata.homeDir when present in preference to the hub HOME (Codex #34 P2)', async () => {
// Use a SEPARATE home dir than the harness's `h.home`, set on
// metadata. The legacy chat must live under the metadata-recorded
// home; the migrator should pick that path, not the hub HOME.
const ownerHome = mkdtempSync(join(tmpdir(), 'hapi-owner-home-'))
try {
const cursorSessionId = 'owner-home-uuid'
const ownerChatsDir = join(ownerHome, '.cursor', 'chats', 'wsh-owner', cursorSessionId)
mkdirSync(ownerChatsDir, { recursive: true })
require('./fixtures/buildSyntheticLegacyStore').buildSyntheticLegacyStore({
path: join(ownerChatsDir, 'store.db')
})
// Hub HOME (h.home) has NO matching chat — only metadata.homeDir does.
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, homeDir: ownerHome } as Metadata
})
// Re-route the migrator's resolution: even though h.home points to
// a temp dir without the chat, the migrator should resolve under
// ownerHome (metadata.homeDir).
// The acp-sessions placement targets the SAME ownerHome since
// home is now resolved from metadata.
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {})
expect(out.ok).toBe(true)
if (!out.ok) return
expect(existsSync(join(ownerHome, '.cursor', 'acp-sessions', cursorSessionId, 'store.db'))).toBe(true)
} finally {
try { rmSync(ownerHome, { recursive: true, force: true }) } catch {}
}
})
it('cp leaves the placed store.db non-empty (sanity)', async () => {
const cursorSessionId = 'sanity-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {})
expect(out.ok).toBe(true)
const acpStorePath = join(h.acpSessionsDir, cursorSessionId, 'store.db')
const st = statSync(acpStorePath)
expect(st.size).toBeGreaterThan(0)
})
})