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>
This commit is contained in:
HeavyGee
2026-06-10 13:57:55 +08:00
committed by GitHub
co-authored by Cursor
parent ddf3a5545b
commit 55d1bbb7bd
21 changed files with 4539 additions and 7 deletions
+2
View File
@@ -1074,6 +1074,8 @@
"@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.1", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-VWPCKdAgwfUNBRI9Xy14CKjx1d7JS1irOja5l6zufpaTi139jc51gyDcWFfygMwttQlNimmh2qHTfaFqqvcdNg=="],
"@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.1", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-kHsA3aV9LlIbI0kpqeF8oFeSCLCubaGVHnC3l5AH51KbvlDGeYzXyr1S8KEdgVgd1Gg3cS6LmwYL/xnyr6WO5Q=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
+358
View File
@@ -0,0 +1,358 @@
/**
* Unit tests for the AcpVerifyProbe lock-acquisition primitives.
*
* The probe spawns a real `agent acp` in production. These tests only
* cover the lock dance (start/stop side effects on the agent-acp-active
* lock dir), NOT the RPC behaviour — that's covered by the integration
* tests with a real agent binary.
*/
import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { AcpVerifyProbe, tryAcquireAcpActiveLock } from './acpVerifyProbe'
describe('AcpVerifyProbe — agent-acp-active lock acquisition (Codex #34 P2 v2)', () => {
let hapiHome: string
beforeEach(() => {
hapiHome = mkdtempSync(join(tmpdir(), 'hapi-acp-lock-test-'))
})
afterEach(() => {
try { rmSync(hapiHome, { recursive: true, force: true }) } catch {}
})
function lockDir(home: string): string {
return join(home, 'locks', 'agent-acp-active')
}
it('acquires the lock atomically when no holder exists (and releases on stop)', async () => {
const probe = new AcpVerifyProbe({
agentBinary: '/usr/bin/env', // any executable; we'll stop() before sending RPC
hapiHome
})
// Spawn would normally fail RPC, but the start path itself should
// succeed up through agent spawn — what we care about here is the
// lock side-effect.
probe.start()
expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true)
await probe.stop()
expect(existsSync(lockDir(hapiHome))).toBe(false)
})
it('throws when the lock is held by another live process (atomic refusal)', () => {
// Pre-create the lock dir with a pid that IS alive (our own pid).
mkdirSync(lockDir(hapiHome), { recursive: true })
writeFileSync(join(lockDir(hapiHome), 'pid'), String(process.pid))
const probe = new AcpVerifyProbe({
agentBinary: '/usr/bin/env',
hapiHome
})
expect(() => probe.start()).toThrow(/agent-acp-active lock is held/)
// Pre-existing lock dir must NOT be removed by the failed acquire.
expect(existsSync(lockDir(hapiHome))).toBe(true)
})
it('clears a stale lock (dead pid file present) and acquires on retry', async () => {
// Pre-create with a pid that is virtually certain to be dead.
mkdirSync(lockDir(hapiHome), { recursive: true })
writeFileSync(join(lockDir(hapiHome), 'pid'), '999999') // typical max_pid; if collides, test is slightly flaky but unlikely on CI
const probe = new AcpVerifyProbe({
agentBinary: '/usr/bin/env',
hapiHome
})
probe.start()
// We acquired by clearing the stale dir and re-creating it.
expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true)
await probe.stop()
expect(existsSync(lockDir(hapiHome))).toBe(false)
})
it('refuses on a pidless lock dir (mid-startup race, Codex #34 P2 v3)', () => {
// Pre-create an EMPTY lock dir without a pid file — this is what
// the CLI guard's registerActiveAcpTransport looks like in the
// tiny window between mkdir and writeFileSync. Treating it as
// "stale because no pid" would clobber the freshly-starting CLI
// ACP transport.
mkdirSync(lockDir(hapiHome), { recursive: true })
const probe = new AcpVerifyProbe({
agentBinary: '/usr/bin/env',
hapiHome
})
expect(() => probe.start()).toThrow(/agent-acp-active lock is held/)
// The pidless lock dir must still be intact.
expect(existsSync(lockDir(hapiHome))).toBe(true)
})
it('stop() does not remove a lock dir owned by another holder', async () => {
const probe = new AcpVerifyProbe({
agentBinary: '/usr/bin/env',
hapiHome
})
probe.start()
// Simulate another process clobbering the pid file before our stop().
writeFileSync(join(lockDir(hapiHome), 'pid'), String(process.pid + 1))
await probe.stop()
// Lock dir is preserved because we no longer own the pid file.
expect(existsSync(lockDir(hapiHome))).toBe(true)
})
it('skipLockAcquire makes start() bypass internal acquire and stop() bypass release (Codex #34 P2 v7)', async () => {
// Caller (migrator) holds the lock externally.
const externalHandle = tryAcquireAcpActiveLock(hapiHome)
expect(externalHandle).not.toBeNull()
if (!externalHandle) return
const probe = new AcpVerifyProbe({
agentBinary: '/usr/bin/env',
hapiHome,
skipLockAcquire: true
})
// start() must NOT throw 'lock is held' — it skipped its internal acquire.
probe.start()
// Lock still held by the external handle.
expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true)
await probe.stop()
// Lock is still held — probe.stop() did NOT release it.
expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true)
externalHandle.release()
expect(existsSync(lockDir(hapiHome))).toBe(false)
})
it('falls back to $HOME/.local/bin and $HOME/.npm-global/bin in PATH when spawning agent (live dogfood 2026-06-07 regression: hub systemd unit ships minimal PATH AND the migrator overrides HOME to a tmpdir for isolation)', async () => {
// The dogfood failure mode: hapi-hub.service ships minimal PATH and
// never sees ~/.local/bin/agent. The migrator additionally overrides
// HOME for the verify probe (HAPI_HOME isolation) — so any naive
// augmentation that derives bin paths from baseEnv.HOME points at a
// tmpdir that doesn't contain agent.
//
// After codex #34 P2 (round 13): the probe accepts an explicit
// `agentLookupHome` option (caller threads its recorded session-
// owner home). Falls back to process.env.HOME when not provided.
// We pin BOTH: explicit option AND fallback. We also pin that the
// existing PATH wins over the fallback (precedence preservation —
// codex #34 P2 round-13 finding F3).
const stubHome = mkdtempSync(join(tmpdir(), 'hapi-probe-stub-home-'))
const stubBin = join(stubHome, '.local', 'bin')
mkdirSync(stubBin, { recursive: true })
writeFileSync(join(stubBin, 'agent'), '#!/bin/sh\nexit 99\n', { mode: 0o755 })
const fakeOverrideHome = mkdtempSync(join(tmpdir(), 'hapi-probe-override-home-'))
// Deliberately NO .local/bin under fakeOverrideHome.
// Case A: explicit agentLookupHome wins, env.HOME irrelevant.
try {
const probe = new AcpVerifyProbe({
hapiHome,
agentLookupHome: stubHome,
env: { HOME: fakeOverrideHome, PATH: '/usr/bin:/bin' }
})
probe.start()
const exited = await new Promise<{ code: number | null }>((resolve) => {
const interval = setInterval(() => {
if (probe['proc'] && probe['proc'].exitCode !== null) {
clearInterval(interval)
resolve({ code: probe['proc'].exitCode })
}
}, 10)
setTimeout(() => { clearInterval(interval); resolve({ code: -1 }) }, 2000)
})
expect(exited.code).toBe(99)
await probe.stop()
} finally {
// intentionally leave stubHome in place for case B
}
// Case B: no agentLookupHome → falls back to process.env.HOME.
const originalHome = process.env.HOME
process.env.HOME = stubHome
try {
const probe = new AcpVerifyProbe({
hapiHome,
env: { HOME: fakeOverrideHome, PATH: '/usr/bin:/bin' }
})
probe.start()
const exited = await new Promise<{ code: number | null }>((resolve) => {
const interval = setInterval(() => {
if (probe['proc'] && probe['proc'].exitCode !== null) {
clearInterval(interval)
resolve({ code: probe['proc'].exitCode })
}
}, 10)
setTimeout(() => { clearInterval(interval); resolve({ code: -1 }) }, 2000)
})
expect(exited.code).toBe(99)
await probe.stop()
} finally {
if (originalHome === undefined) delete process.env.HOME
else process.env.HOME = originalHome
try { rmSync(stubHome, { recursive: true, force: true }) } catch {}
try { rmSync(fakeOverrideHome, { recursive: true, force: true }) } catch {}
}
})
it('preserves explicit options.env.PATH precedence over the cursor-bin fallback (Codex #34 P2 round-13 F3)', async () => {
// When the caller deliberately supplies options.env.PATH with a
// pinned `agent` (e.g. a staging Cursor install or a wrapper),
// the cursor-bin fallback must NOT override it. We test by giving
// BOTH a winning PATH entry (priorityBin) AND a fallback entry
// (fallbackBin) and asserting the priority wins.
const priorityHome = mkdtempSync(join(tmpdir(), 'hapi-probe-priority-'))
const priorityBin = join(priorityHome, 'bin')
mkdirSync(priorityBin, { recursive: true })
writeFileSync(join(priorityBin, 'agent'), '#!/bin/sh\nexit 11\n', { mode: 0o755 })
const fallbackHome = mkdtempSync(join(tmpdir(), 'hapi-probe-fallback-'))
const fallbackBin = join(fallbackHome, '.local', 'bin')
mkdirSync(fallbackBin, { recursive: true })
writeFileSync(join(fallbackBin, 'agent'), '#!/bin/sh\nexit 22\n', { mode: 0o755 })
try {
const probe = new AcpVerifyProbe({
hapiHome,
agentLookupHome: fallbackHome,
env: { PATH: priorityBin } // explicit PATH wins; fallback bins appended
})
probe.start()
const exited = await new Promise<{ code: number | null }>((resolve) => {
const interval = setInterval(() => {
if (probe['proc'] && probe['proc'].exitCode !== null) {
clearInterval(interval)
resolve({ code: probe['proc'].exitCode })
}
}, 10)
setTimeout(() => { clearInterval(interval); resolve({ code: -1 }) }, 2000)
})
expect(exited.code).toBe(11) // priority wins, not 22 (fallback)
await probe.stop()
} finally {
try { rmSync(priorityHome, { recursive: true, force: true }) } catch {}
try { rmSync(fallbackHome, { recursive: true, force: true }) } catch {}
}
})
it('joins augmented PATH with path.delimiter (Codex #34 P2 round-13 F1: Windows uses ; not :)', async () => {
// Indirect assertion via spawn behaviour: on linux the delimiter is
// `:`. We can't actually drive a win32 spawn from this test runner,
// but we can confirm the join uses path.delimiter by checking that
// the augmented PATH contains a path.delimiter between segments,
// not a hardcoded ':'. Reach into the spawn env via a stubbed
// agent that prints its PATH.
const stubHome = mkdtempSync(join(tmpdir(), 'hapi-probe-delim-'))
const stubBin = join(stubHome, '.local', 'bin')
mkdirSync(stubBin, { recursive: true })
writeFileSync(
join(stubBin, 'agent'),
'#!/bin/sh\nprintenv PATH > "$0.path"\nexit 33\n',
{ mode: 0o755 }
)
try {
const probe = new AcpVerifyProbe({
hapiHome,
agentLookupHome: stubHome,
env: { PATH: '/usr/bin' }
})
probe.start()
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (probe['proc'] && probe['proc'].exitCode !== null) {
clearInterval(interval)
resolve()
}
}, 10)
setTimeout(() => { clearInterval(interval); resolve() }, 2000)
})
await probe.stop()
const pathFile = join(stubBin, 'agent.path')
const seenPath = existsSync(pathFile)
? require('node:fs').readFileSync(pathFile, 'utf8').trim()
: ''
// Should be `/usr/bin<delim><stubHome>/.local/bin<delim><stubHome>/.npm-global/bin`
// on linux this means `/usr/bin:/tmp/.../.local/bin:/tmp/.../.npm-global/bin`
expect(seenPath).toContain('/usr/bin')
expect(seenPath).toContain(`${stubHome}/.local/bin`)
// Existing PATH first, fallback appended.
const usrBinIdx = seenPath.indexOf('/usr/bin')
const fallbackIdx = seenPath.indexOf(`${stubHome}/.local/bin`)
expect(usrBinIdx).toBeLessThan(fallbackIdx)
} finally {
try { rmSync(stubHome, { recursive: true, force: true }) } catch {}
}
})
})
describe('tryAcquireAcpActiveLock (Codex #34 P2 v7)', () => {
let hapiHome: string
beforeEach(() => {
hapiHome = mkdtempSync(join(tmpdir(), 'hapi-acp-lock-helper-test-'))
})
afterEach(() => {
try { rmSync(hapiHome, { recursive: true, force: true }) } catch {}
})
function lockDir(home: string): string {
return join(home, 'locks', 'agent-acp-active')
}
it('returns a handle on a clean home; release() removes the lock dir', () => {
const h = tryAcquireAcpActiveLock(hapiHome)
expect(h).not.toBeNull()
if (!h) return
expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true)
h.release()
expect(existsSync(lockDir(hapiHome))).toBe(false)
})
it('returns null when another live process holds the lock', () => {
// Pre-place an active lock (our pid is alive).
mkdirSync(lockDir(hapiHome), { recursive: true })
writeFileSync(join(lockDir(hapiHome), 'pid'), String(process.pid))
const h = tryAcquireAcpActiveLock(hapiHome)
expect(h).toBeNull()
// Existing lock dir not clobbered.
expect(existsSync(lockDir(hapiHome))).toBe(true)
})
it('returns null on a pidless lock dir (mid-startup race)', () => {
mkdirSync(lockDir(hapiHome), { recursive: true })
const h = tryAcquireAcpActiveLock(hapiHome)
expect(h).toBeNull()
expect(existsSync(lockDir(hapiHome))).toBe(true)
})
it('clears a stale lock (dead pid) and acquires on retry', () => {
mkdirSync(lockDir(hapiHome), { recursive: true })
writeFileSync(join(lockDir(hapiHome), 'pid'), '999999')
const h = tryAcquireAcpActiveLock(hapiHome)
expect(h).not.toBeNull()
if (!h) return
h.release()
})
it('release() is idempotent', () => {
const h = tryAcquireAcpActiveLock(hapiHome)
expect(h).not.toBeNull()
if (!h) return
h.release()
h.release() // second call should not throw
expect(existsSync(lockDir(hapiHome))).toBe(false)
})
it('release() removes the lock dir even when the pid file is missing (Codex #34 P2 v7)', () => {
// Simulate the rare case where mkdir succeeded but writeFileSync(pid)
// failed. The handle still owns the dir; release must clean it up.
const h = tryAcquireAcpActiveLock(hapiHome)
expect(h).not.toBeNull()
if (!h) return
// Remove the pid file underneath us.
rmSync(join(lockDir(hapiHome), 'pid'))
h.release()
// Lock dir gone — we own it, we remove it even when pidless.
expect(existsSync(lockDir(hapiHome))).toBe(false)
})
})
+671
View File
@@ -0,0 +1,671 @@
/**
* Minimal JSON-RPC stdio client for `agent acp`.
*
* Hub-side, internal-only: used by the legacy → ACP migrator's verify step to
* confirm that a transplanted store.db can actually be opened by `agent acp`
* before we flip metadata and remove the legacy source.
*
* This is intentionally NOT a full ACP client (those live in cli/src/agent/...).
* It speaks only the three calls verify needs: initialize, session/load, and
* (optionally) session/prompt. It is decoupled from the launcher loop so it
* can spawn against a temp $HOME without engaging any of HAPI's per-session
* machinery.
*/
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, delimiter as pathDelimiter } from 'node:path'
import { tmpdir } from 'node:os'
export interface AcpProbeOptions {
/** Path to the `agent` binary. Default 'agent'. */
agentBinary?: string
/** Override env (used to set HOME for isolation). */
env?: NodeJS.ProcessEnv
/** Default per-request timeout, ms. */
timeoutMs?: number
/**
* Override the $HAPI_HOME directory used for the agent-acp-active lock.
* Defaults to `process.env.HAPI_HOME` (with tmpdir/hapi fallback) — same
* scheme as cli/src/agent/backends/acp/agentCliGuard.ts. Tests can pass a
* temp dir here to avoid clobbering the operator's real lock.
*/
hapiHome?: string
/**
* When true, the probe will NOT acquire the agent-acp-active lock in
* start() and will NOT release it in stop(). Caller is responsible
* for owning the lock for the probe's lifetime. Codex review #34
* P2 v7: the migrator pre-acquires the lock BEFORE archiving so a
* concurrent ACP spawn cannot land in the window between preflight
* and verifyInTempHome.
*/
skipLockAcquire?: boolean
/**
* Recorded operator home dir to use for resolving the `agent` binary
* on PATH. Used when constructing the fallback PATH augmentation
* (~/.local/bin and ~/.npm-global/bin). Defaults to `process.env.HOME`.
*
* Codex review #34 P2: in deployment shapes where the hub runs as a
* service account whose `process.env.HOME` differs from the human
* user who installed Cursor (`metadata.homeDir`), the caller (the
* migrator) needs to thread its recorded session-owner home through
* to the verify probe so the binary lookup happens against the right
* filesystem location. Independent of any HOME override passed via
* `options.env` (which is for the spawned agent's cache/state
* isolation, not its binary lookup).
*/
agentLookupHome?: string
}
/**
* Handle to a held agent-acp-active lock. Created by
* tryAcquireAcpActiveLock(); the caller MUST call release() in a
* finally block.
*/
export interface AcpActiveLockHandle {
/** Absolute path to the lock directory we own. */
lockDir: string
/** Release the lock. Idempotent. */
release(): void
}
/**
* Acquire the global agent-acp-active lock at the well-known path
* `<hapiHome>/locks/agent-acp-active/`. Returns a handle whose
* release() removes the lock dir; returns null if the lock is held
* by another live (or mid-startup) process. Throws on
* non-EEXIST mkdir failures (root-owned HAPI_HOME, read-only fs).
*
* Codex review #34 P2 v7: extracted so the legacy migrator can
* reserve the lock BEFORE archive — closing the gap where another
* agent acp could start between preflight and verifyInTempHome.
*/
export function tryAcquireAcpActiveLock(hapiHome: string): AcpActiveLockHandle | null {
const lockDir = join(hapiHome, 'locks', 'agent-acp-active')
const pidFile = join(lockDir, 'pid')
const parentDir = join(lockDir, '..')
try {
mkdirSync(parentDir, { recursive: true })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!/EEXIST/.test(msg)) {
throw new Error(`agent-acp-active lock parent could not be created (path=${parentDir}): ${msg}`)
}
}
const tryAcquire = (): boolean => {
try {
mkdirSync(lockDir, { recursive: false })
try {
writeFileSync(pidFile, String(process.pid))
} catch {
// pid write best-effort; release will still rmdir.
}
return true
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!/EEXIST/.test(msg)) {
throw new Error(`agent-acp-active lock could not be claimed (path=${lockDir}): ${msg}`)
}
return false
}
}
if (tryAcquire()) {
return makeLockHandle(lockDir, pidFile)
}
// Lock held — decide if stale and retry once.
const probe = inspectLockHolder(pidFile)
if (probe.kind === 'dead') {
try { rmSync(lockDir, { recursive: true, force: true }) } catch {}
if (tryAcquire()) {
return makeLockHandle(lockDir, pidFile)
}
}
return null
}
type LockHolderProbe =
| { kind: 'live'; pid: number }
| { kind: 'dead' }
| { kind: 'starting' }
function inspectLockHolder(pidFile: string): LockHolderProbe {
if (!existsSync(pidFile)) return { kind: 'starting' }
let raw: string
try {
raw = readFileSync(pidFile, 'utf8').trim()
} catch {
return { kind: 'starting' }
}
if (raw.length === 0) return { kind: 'starting' }
const pid = Number(raw)
if (!Number.isInteger(pid) || pid <= 0) return { kind: 'starting' }
try {
process.kill(pid, 0)
return { kind: 'live', pid }
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EPERM') return { kind: 'live', pid }
return { kind: 'dead' }
}
}
function makeLockHandle(lockDir: string, pidFile: string): AcpActiveLockHandle {
let released = false
return {
lockDir,
release() {
if (released) return
released = true
try {
let shouldRemove = true
if (existsSync(pidFile)) {
try {
const raw = readFileSync(pidFile, 'utf8').trim()
if (raw.length > 0 && raw !== String(process.pid)) {
const otherPid = Number(raw)
if (Number.isInteger(otherPid) && otherPid > 0) {
shouldRemove = false
}
}
} catch {
// read error — we own the dir, remove it.
}
}
if (shouldRemove) {
rmSync(lockDir, { recursive: true, force: true })
}
} catch {
// best-effort
}
}
}
}
export type AcpRpcResponse =
| { ok: true; result: Record<string, unknown> }
| { ok: false; error: { code: number; message: string; data?: unknown } }
export type AcpNotification = {
method: string
params: Record<string, unknown>
}
/** Subset of session/load response useful to the migrator. */
export interface AcpLoadOutcome {
response: AcpRpcResponse
notificationCount: number
notificationKinds: Record<string, number>
durationMs: number
}
export interface AcpPromptOutcome {
response: AcpRpcResponse
durationMs: number
}
export class AcpVerifyProbe {
private proc: ChildProcessWithoutNullStreams | null = null
private nextId = 0
private buf = ''
private readonly pending = new Map<number, { resolve: (msg: AcpRpcResponse) => void; timer: NodeJS.Timeout }>()
private readonly notifications: AcpNotification[] = []
private stderr = ''
private readonly defaultTimeoutMs: number
private readonly stderrLimit = 4096
private lockHeld = false
private readonly lockDir: string
private readonly lockPidFile: string
constructor(private readonly options: AcpProbeOptions = {}) {
this.defaultTimeoutMs = options.timeoutMs ?? 20_000
const home = options.hapiHome ?? process.env.HAPI_HOME?.trim() ?? join(tmpdir(), 'hapi')
this.lockDir = join(home, 'locks', 'agent-acp-active')
this.lockPidFile = join(this.lockDir, 'pid')
}
start(): void {
if (this.proc) return
// Codex review #34 P2: register the agent-acp-active lock BEFORE
// spawn so concurrent migrations / model-list requests see the
// probe as a live ACP transport and back off. Without this, two
// parallel migrations could each pass the pre-spawn check, both
// spawn agent acp, and the second one's spawn would SIGTERM the
// first per Cursor's single-instance enforcement (see
// cli/src/agent/backends/acp/agentCliGuard.ts top comment).
//
// Codex review #34 P2 v7: when the caller (typically the legacy
// migrator) has already acquired the lock at the start of its
// critical section, skip our internal acquire so we don't fail
// EEXIST against the caller's own lock. The caller is then
// responsible for releasing.
if (!this.options.skipLockAcquire) {
this.acquireLock()
}
// Codex review #34 P2: match cursorAcpRemoteLauncher's spawn shape
// so the `agent.cmd` shim on Windows is reachable. Without
// shell:true + windowsHide:true the spawn fails with ENOENT even
// though normal Cursor ACP sessions work.
const isWin = process.platform === 'win32'
// Live dogfood (2026-06-07) on hapi-hub.service surfaced
// `Executable not found in $PATH: "agent"` — the hub's systemd unit
// ships a minimal PATH (/usr/local/sbin:/usr/local/bin:/usr/sbin:
// /usr/bin:/sbin:/bin) and never sees Cursor's standard install
// location at ~/.local/bin/agent. hapi-runner.service hand-fixes
// this via Environment=PATH=$HOME/.local/bin:... in its unit file;
// we replicate that in code so the hub doesn't depend on the
// operator hand-tuning their systemd dropin.
//
// Resolution order for the lookup home:
// 1. options.agentLookupHome — caller-supplied (migrator threads
// its recorded session-owner homeDir here; covers the service-
// account-hub deployment where process.env.HOME and the human
// user who installed Cursor differ)
// 2. process.env.HOME — hub process's own home (covers the
// common single-user deployment)
//
// Independently of where the LOOKUP home comes from, options.env
// may override HOME for the spawned agent's cache/state isolation
// (HAPI_HOME-style sandboxing in the migrator's verifyInTempHome).
// The two HOMEs are deliberately separate concerns.
//
// PATH precedence: we APPEND the fallback bin dirs after the
// existing PATH, so any explicit options.env.PATH (e.g. a staging
// Cursor install or a pinned wrapper) wins. The fallback only
// kicks in when the existing PATH doesn't already contain `agent`.
//
// Platform: use path.delimiter (`;` on win32, `:` elsewhere) so the
// augmented PATH is valid for cmd.exe when this spawn path
// delegates to the shell for `agent.cmd`.
const baseEnv = this.options.env ?? process.env
const lookupHome = this.options.agentLookupHome ?? process.env.HOME ?? ''
const cursorBins = lookupHome
? [join(lookupHome, '.local', 'bin'), join(lookupHome, '.npm-global', 'bin')]
: []
const existingPath = baseEnv.PATH ?? ''
const augmentedPath = [existingPath, ...cursorBins].filter(Boolean).join(pathDelimiter)
const spawnEnv = { ...baseEnv, PATH: augmentedPath }
const proc = spawn(this.options.agentBinary ?? 'agent', ['acp'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: spawnEnv,
shell: isWin,
windowsHide: isWin
})
this.proc = proc
proc.stdout.on('data', (chunk: Buffer) => this.handleStdout(chunk.toString('utf8')))
proc.stderr.on('data', (chunk: Buffer) => {
this.stderr += chunk.toString('utf8')
if (this.stderr.length > this.stderrLimit) {
this.stderr = this.stderr.slice(-this.stderrLimit)
}
})
// If the child dies, fail all pending requests so callers see a
// structured rejection instead of a hang.
proc.on('error', (err) => this.failPending(err))
proc.on('exit', (code, signal) => {
if (this.pending.size > 0) {
this.failPending(new Error(`agent acp exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`))
}
})
}
async stop(): Promise<void> {
const proc = this.proc
this.proc = null
if (proc) {
// Codex review #34 P2 v7: on Windows we spawn through a shell
// (shell: true in start()) so proc.kill only signals the shell
// wrapper — the `agent` child can survive. Use taskkill /F /T
// to kill the process tree. POSIX kill propagates to the
// process group via SIGTERM as long as the child didn't fork.
if (process.platform === 'win32' && proc.pid !== undefined) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('node:child_process').execSync(`taskkill /F /T /PID ${proc.pid}`, { stdio: 'ignore', windowsHide: true })
} catch {
// best-effort — fall through to SIGTERM as a backup
try { proc.kill('SIGTERM') } catch {}
}
} else {
try {
proc.kill('SIGTERM')
} catch {
// best-effort
}
}
// Codex review #34 P2 v6: wait for the child to actually exit
// before releasing the agent-acp-active lock. Sending SIGTERM
// does not mean the process is dead — agent acp may take a
// few hundred ms to tear down JSON-RPC and detach stdio. If
// we release the lock before then, the next session's
// verifier (or any other CLI guard caller) can acquire the
// free lock and spawn a second `agent acp` while ours is
// still live, hitting Cursor's single-instance enforcement
// and SIGTERMing one or both.
const alreadyDone = proc.exitCode !== null || proc.signalCode !== null
if (!alreadyDone) {
await new Promise<void>((resolve) => {
let resolved = false
const done = () => {
if (resolved) return
resolved = true
resolve()
}
proc.once('exit', done)
proc.once('close', done)
// Hard ceiling so a wedged child cannot hang the
// migrator's finally{} block forever. After this
// ceiling we fall through to releaseLock and accept
// the (now extremely rare) overlap window.
setTimeout(done, 5000)
})
}
// Drain any remaining pending requests with a kill error so the caller
// does not deadlock waiting on a JSON-RPC response that will never arrive.
this.failPending(new Error('agent acp killed by probe.stop()'))
}
// Release the lock LAST (after kill + exit-wait) so concurrent
// requests still see us as active during the entire teardown
// window. Codex review #34 P2 / P2 v6.
//
// Codex review #34 P2 v7: when skipLockAcquire was set, the
// caller owns the lock for a longer scope than this probe
// instance — don't release theirs.
if (!this.options.skipLockAcquire) {
this.releaseLock()
}
}
private acquireLock(): void {
// Ensure the parent dir exists (idempotent), then atomically claim
// the lock dir itself via mkdirSync(recursive:false). The atomic
// mkdir fails EEXIST if another lock-holder is already in place
// — that is the only race-safe primitive here. mkdirSync + write
// is NOT atomic and would let two concurrent migrations both
// think they own the lock. Codex review #34 P2 v2.
const parentDir = join(this.lockDir, '..')
try {
mkdirSync(parentDir, { recursive: true })
} catch (err) {
// Codex review #34 P2 v7: previously silently swallowed. If
// we can't even create the parent dir (root-owned HAPI_HOME,
// read-only fs), we cannot claim a lock. Fail loud so start()
// refuses rather than running unguarded.
const msg = err instanceof Error ? err.message : String(err)
if (!/EEXIST/.test(msg)) {
throw new Error(`agent-acp-active lock parent could not be created (path=${parentDir}): ${msg}`)
}
}
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
mkdirSync(this.lockDir, { recursive: false })
// Won the race — write our pid IMMEDIATELY (no async work
// between the mkdir and the write) so other callers see a
// pidful lock as soon as possible. The CLI guard's
// clearStaleAcpLockIfNeeded ALSO no longer removes
// pid-less dirs (Codex review #34 P3 v6), but tightening
// the window here belt-and-suspenders the protection.
try {
writeFileSync(this.lockPidFile, String(process.pid))
} catch {
// best-effort: pid file is diagnostic, not the
// primary lock primitive (the dir is). releaseLock
// will still rmdir on our pid-less lock because
// lockHeld=true means we own the mkdir. Codex
// review #34 P2 v7.
}
this.lockHeld = true
return
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!/EEXIST/.test(msg)) {
// Codex review #34 P2 v7: a non-EEXIST mkdir failure
// (permission denied, read-only fs, disk full) means
// we cannot acquire the lock. Previously we silently
// returned with lockHeld=false and start() would
// spawn agent acp UNGUARDED. Throw instead so
// verifyInTempHome surfaces the refusal cleanly.
throw new Error(`agent-acp-active lock could not be claimed (path=${this.lockDir}): ${msg}`)
}
// Lock dir exists. Decide whether the holder is stale.
// Codex review #34 P2 v3: a CLI agent guard that just
// mkdir'd the lock but has NOT yet written the pid file
// would otherwise look "stale" to us and we would delete
// their freshly-created live lock. Treat a missing-pid
// lock as ACTIVE (probably mid-startup) on the first
// attempt; only consider it stale if a pid file IS
// present AND the recorded pid is dead.
const probe = this.probeLockHolder()
if (attempt === 0 && probe.kind === 'dead') {
try { rmSync(this.lockDir, { recursive: true, force: true }) } catch {}
continue
}
// Either live, or mid-startup (no pid yet), or we already
// retried once. Refuse.
throw new Error(`agent-acp-active lock is held (path=${this.lockDir}, holder=${probe.kind === 'live' ? `pid=${probe.pid}` : probe.kind}); refusing to spawn a second agent acp`)
}
}
}
/** Inspect the current holder of an existing lock dir. */
private probeLockHolder(): { kind: 'live'; pid: number } | { kind: 'dead' } | { kind: 'starting' } {
try {
if (!existsSync(this.lockPidFile)) {
// Lock dir exists but pid file not yet written — caller
// is in the middle of registerActiveAcpTransport(). Treat
// as live to avoid racing them.
return { kind: 'starting' }
}
const raw = readFileSync(this.lockPidFile, 'utf8').trim()
const pid = Number(raw)
if (!Number.isInteger(pid) || pid <= 0) {
// Malformed pid file — treat as starting (caller may be
// mid-write) rather than dead.
return { kind: 'starting' }
}
try {
process.kill(pid, 0)
return { kind: 'live', pid }
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EPERM') return { kind: 'live', pid }
return { kind: 'dead' }
}
} catch {
return { kind: 'starting' }
}
}
private releaseLock(): void {
if (!this.lockHeld) return
this.lockHeld = false
// We own the mkdir (lockHeld was set true by the EEXIST-free
// mkdirSync above). Remove the lock dir in two cases:
// (a) pid file is OUR pid (normal happy path), OR
// (b) pid file is missing/unparseable — Codex review #34 P2 v7:
// we own the dir, our pid-write failed (disk full etc.).
// The CLI guard now treats pid-less dirs as "starting"
// (active), so leaving this here would wedge the lock
// forever. We OWN it; we must clean it up.
// Skip removal only when the pid file has a DIFFERENT, valid pid
// — that would mean another holder somehow took over (shouldn't
// happen but defensive).
try {
let shouldRemove = true
if (existsSync(this.lockPidFile)) {
try {
const raw = readFileSync(this.lockPidFile, 'utf8').trim()
if (raw.length > 0 && raw !== String(process.pid)) {
const otherPid = Number(raw)
if (Number.isInteger(otherPid) && otherPid > 0) {
shouldRemove = false
}
}
} catch {
// read error — we own the dir, remove it.
}
}
if (shouldRemove) {
rmSync(this.lockDir, { recursive: true, force: true })
}
} catch {
// best-effort
}
}
getStderr(): string {
return this.stderr
}
getNotifications(): AcpNotification[] {
return [...this.notifications]
}
clearNotifications(): void {
this.notifications.length = 0
}
/** Send `initialize` and return the response. */
initialize(timeoutMs?: number): Promise<AcpRpcResponse> {
return this.send('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
terminal: false
},
clientInfo: { name: 'hapi-cursor-legacy-migrator-verify', version: '1' }
}, timeoutMs)
}
/** Send `session/load` and capture replay notifications drained over `replayDrainMs`. */
async loadSession(params: { sessionId: string; cwd: string; mcpServers?: unknown[] }, replayDrainMs: number = 3_000, timeoutMs?: number): Promise<AcpLoadOutcome> {
const start = Date.now()
const before = this.notifications.length
const response = await this.send('session/load', {
sessionId: params.sessionId,
cwd: params.cwd,
mcpServers: params.mcpServers ?? []
}, timeoutMs)
if (!response.ok) {
return {
response,
notificationCount: 0,
notificationKinds: {},
durationMs: Date.now() - start
}
}
if (replayDrainMs > 0) {
await sleep(replayDrainMs)
}
const drained = this.notifications.slice(before)
const notificationKinds: Record<string, number> = {}
for (const n of drained) {
const u = (n.params as Record<string, unknown>)?.update as Record<string, unknown> | undefined
const kind = typeof u?.sessionUpdate === 'string' ? u.sessionUpdate : '_other'
notificationKinds[kind] = (notificationKinds[kind] ?? 0) + 1
}
return {
response,
notificationCount: drained.length,
notificationKinds,
durationMs: Date.now() - start
}
}
async prompt(params: { sessionId: string; text: string }, timeoutMs: number = 60_000): Promise<AcpPromptOutcome> {
const start = Date.now()
const response = await this.send('session/prompt', {
sessionId: params.sessionId,
prompt: [{ type: 'text', text: params.text }]
}, timeoutMs)
return { response, durationMs: Date.now() - start }
}
async setModel(params: { sessionId: string; modelId: string }, timeoutMs?: number): Promise<AcpRpcResponse> {
return this.send('session/set_model', { sessionId: params.sessionId, modelId: params.modelId }, timeoutMs)
}
// ---------------------------------------------------------------------
private send(method: string, params: unknown, timeoutMs?: number): Promise<AcpRpcResponse> {
if (!this.proc) {
return Promise.resolve({ ok: false as const, error: { code: -32603, message: 'agent acp not started' } })
}
const id = ++this.nextId
const t = timeoutMs ?? this.defaultTimeoutMs
const req = { jsonrpc: '2.0', id, method, params }
const stdin = this.proc.stdin
return new Promise<AcpRpcResponse>((resolve) => {
const timer = setTimeout(() => {
this.pending.delete(id)
resolve({ ok: false, error: { code: -32603, message: `timeout ${method} after ${t}ms`, data: { stderr_tail: this.stderr.slice(-512) } } })
}, t)
this.pending.set(id, { resolve, timer })
try {
stdin.write(`${JSON.stringify(req)}\n`)
} catch (err) {
clearTimeout(timer)
this.pending.delete(id)
resolve({ ok: false, error: { code: -32603, message: `stdin write failed: ${err instanceof Error ? err.message : String(err)}` } })
}
})
}
private handleStdout(chunk: string): void {
this.buf += chunk
let idx: number
while ((idx = this.buf.indexOf('\n')) !== -1) {
const line = this.buf.slice(0, idx).trim()
this.buf = this.buf.slice(idx + 1)
if (!line) continue
let msg: Record<string, unknown>
try {
msg = JSON.parse(line) as Record<string, unknown>
} catch {
continue
}
const id = msg.id
if (typeof id === 'number' && this.pending.has(id)) {
const entry = this.pending.get(id)!
this.pending.delete(id)
clearTimeout(entry.timer)
if (msg.error && typeof msg.error === 'object') {
const err = msg.error as Record<string, unknown>
entry.resolve({
ok: false,
error: {
code: typeof err.code === 'number' ? err.code : -32603,
message: typeof err.message === 'string' ? err.message : 'agent acp error',
data: err.data
}
})
} else if (msg.result && typeof msg.result === 'object') {
entry.resolve({ ok: true, result: msg.result as Record<string, unknown> })
} else {
entry.resolve({ ok: false, error: { code: -32603, message: 'malformed agent acp response' } })
}
} else if (typeof msg.method === 'string' && msg.params && typeof msg.params === 'object') {
this.notifications.push({
method: msg.method as string,
params: msg.params as Record<string, unknown>
})
}
}
}
private failPending(err: Error): void {
for (const [id, entry] of this.pending.entries()) {
clearTimeout(entry.timer)
entry.resolve({ ok: false, error: { code: -32603, message: err.message } })
this.pending.delete(id)
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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')
})
})
@@ -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()
}
}
+13 -4
View File
@@ -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
@@ -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 })
@@ -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}. ` +
+338 -3
View File
@@ -8,7 +8,7 @@
*/
import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol'
import type { SlashCommandsResponse } from '@hapi/protocol/apiTypes'
import type { 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'
@@ -16,6 +16,8 @@ import type { Store, CancelQueuedMessageResult } from '../store'
import type { HapiSessionExportResult } from '@hapi/protocol/sessionExport'
import type { RpcRegistry } from '../socket/rpcRegistry'
import type { SSEManager } from '../sse/sseManager'
import { CursorLegacyMigrator, type CursorLegacyMigratorOptions } from '../cursor/cursorLegacyMigrator'
import { EventPublisher, type SyncEventListener } from './eventPublisher'
import { MachineCache, type Machine } from './machineCache'
import { MessageService } from './messageService'
@@ -430,6 +432,154 @@ export class SyncEngine {
this.handleSessionEnd({ sid: sessionId, time: Date.now() })
}
/**
* Apply the post-migration metadata flip in hapi.db:
* - metadata.cursorSessionProtocol = 'acp'
* - session.model = lastUsedModel (if provided)
*
* Returns 'success' on a clean write, 'version-mismatch' if the metadata
* version moved underneath us (caller retries) or 'not-found' if the row
* is gone.
*
* Used by CursorLegacyMigrator after the on-disk transplant + verify
* succeeds. Kept on the engine (not on the migrator) so that all hapi.db
* writes funnel through the existing cache-refresh path.
*/
flipCursorSessionProtocolToAcp(
sessionId: string,
namespace: string,
lastUsedModel: string | null
): { result: 'success' | 'version-mismatch' | 'not-found' | 'session-active' } {
for (let attempt = 0; attempt < 2; attempt += 1) {
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!latest?.metadata) {
return { result: 'not-found' }
}
// Combined SSE-event payload contract (UX A++): clear the
// `cursorMigrationState='in_progress'` flag in the SAME metadata
// write that flips `cursorSessionProtocol` to 'acp'. The web
// banner keys off `cursorMigrationState`, so a single SSE
// session-updated event swaps both atomically — banner gone,
// protocol flipped — preventing a flicker window where the
// banner has already disappeared but the chat hasn't re-rendered
// to the ACP transport yet.
const carriedMigrationState = latest.metadata.cursorMigrationState
// Atomic active-check inside the same synchronous flip op so
// that a resume cannot land between the migrator's recheck
// and the actual DB update. Bun is single-threaded — once
// we've read `latest` and the row is inactive, no other JS
// can mutate active=true until this method returns. Codex
// review #34 P1 v2: the migrator's recheck is best-effort;
// this is the authoritative gate.
//
// Codex review #34 P2 v5: only block on `active === true`,
// NOT on lifecycleState === 'running'. After a force-archive
// flow archiveSession() synchronously sets active=false but
// the cleanup metadata write that flips lifecycleState
// 'running' → 'archived' may still be in-flight, and that
// is OUR archive completing, not a resume race. The active
// flag is the authoritative live-runner signal.
if (latest.active === true) {
return { result: 'session-active' }
}
// Codex review #34 P2 v7: ALSO clear a stale lifecycleState
// value if it still says 'running'. The migrator now skips
// archiveSession() for stale-running rows (active=false but
// lifecycle=running with --force-archive-running) because
// there's no live runner to archive. Without this fixup,
// successfully migrated stale rows would retain lifecycle=
// running forever and any downstream code that filters by
// lifecycleState (not the cache active flag) would keep
// treating archived ACP sessions as live.
const oldLifecycle = typeof latest.metadata.lifecycleState === 'string' ? latest.metadata.lifecycleState : undefined
const nextMetadata: typeof latest.metadata = {
...latest.metadata,
cursorSessionProtocol: 'acp' as const,
...(oldLifecycle === 'running' ? { lifecycleState: 'archived' as const } : {})
}
// Drop the migration-in-progress flag in the same write (see
// header comment). Safe whether or not it was set.
if (carriedMigrationState !== undefined) {
delete nextMetadata.cursorMigrationState
}
const result = this.store.sessions.updateSessionMetadata(
sessionId,
nextMetadata,
latest.metadataVersion,
namespace,
{ touchUpdatedAt: false }
)
if (result.result === 'version-mismatch') {
this.sessionCache.refreshSession(sessionId)
continue
}
if (result.result !== 'success') {
return { result: 'not-found' }
}
this.sessionCache.refreshSession(sessionId)
if (lastUsedModel && lastUsedModel.trim().length > 0) {
this.store.sessions.setSessionModel(sessionId, lastUsedModel.trim(), namespace, { touchUpdatedAt: false })
this.sessionCache.refreshSession(sessionId)
}
return { result: 'success' }
}
return { result: 'version-mismatch' }
}
/**
* Migrate a single legacy cursor session to ACP. Hub-side; runs on the
* operator's machine (the hub host); see tiann/hapi#824 design.
* Returns a structured outcome (ok or refusal); does not throw.
*/
async migrateLegacyCursorSession(
sessionId: string,
namespace: string,
request: CursorMigrateToAcpRequest
): Promise<CursorMigrateOutcome> {
const session = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!session) {
return { ok: false, sessionId, reason: 'internal_error', message: 'session not found in namespace', durationMs: 0 }
}
const migrator = this.buildMigratorForRequest(request)
return migrator.migrateOne(session, {
keepSource: request.keepSource,
forceArchiveRunning: request.forceArchiveRunning,
skipVerify: request.skipVerify
})
}
private buildMigratorForRequest(_request: CursorMigrateToAcpRequest): CursorLegacyMigrator {
const migratorOpts: CursorLegacyMigratorOptions = {}
return new CursorLegacyMigrator(migratorOpts, {
archiveSession: async (sessionId) => {
await this.archiveSession(sessionId)
},
// NOTE: no awaitSessionInactive injection — handleSessionEnd()
// synchronously sets cache.active=false inside archiveSession,
// so any cache-based poll would return immediately and provide
// false reassurance. The migrator now relies on
// awaitLockRelease's minimum-dwell + SQLite busy-probe +
// size-stability combination instead. Codex review #34 P1 v3.
getCurrentSession: (sessionId, namespace) => {
const s = this.sessionCache.getSessionByNamespace(sessionId, namespace)
if (!s) return null
return {
active: s.active === true,
lifecycleState: typeof s.metadata?.lifecycleState === 'string' ? s.metadata.lifecycleState : undefined,
cursorSessionProtocol: typeof s.metadata?.cursorSessionProtocol === 'string' ? s.metadata.cursorSessionProtocol : undefined
}
},
updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => {
const result = this.flipCursorSessionProtocolToAcp(sessionId, namespace, lastUsedModel)
if (result.result === 'success') return { ok: true }
if (result.result === 'session-active') return { ok: false, reason: 'session_active' as const }
return { ok: false, reason: 'version_mismatch_or_missing' as const }
}
})
}
async switchSession(sessionId: string, to: 'remote' | 'local'): Promise<void> {
await this.rpcGateway.switchSession(sessionId, to)
}
@@ -633,6 +783,183 @@ export class SyncEngine {
return undefined
}
/**
* tiann/hapi#824 — sync-on-open auto-migration. Returns the (possibly
* refreshed-from-cache) session. If the session is a legacy stream-json
* Cursor session AND the env flag is on, attempts a transplant migration
* synchronously before the caller spawns the runner.
*
* The migrator's verify probe runs in an isolated HAPI_HOME (see
* verifyInTempHome), so this method is safe to call even when other ACP
* transports are alive on the host: per tiann/hapi#832, two `agent acp`
* processes coexist on the same host without conflict, and swear01's
* tiann/hapi#835 refactors the agent-acp-active lock into a cross-process
* refcount that explicitly supports this. We rely on #835 landing before
* this PR — see manifest layer ordering and the dependency note in
* PR #34's body.
*
* Failure modes are all soft — the session is returned unchanged and the
* caller proceeds with the legacy launcher.
*/
private async maybeAutoMigrateLegacyCursorSession(session: Session, namespace: string): Promise<Session> {
const md = session.metadata
const flagRaw = process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE?.trim().toLowerCase() ?? ''
console.info('[auto-migrate] considering', {
sessionId: session.id,
flavor: md?.flavor ?? null,
proto: md?.cursorSessionProtocol ?? null,
hasCursorId: typeof md?.cursorSessionId === 'string' && md.cursorSessionId.length > 0,
envFlag: flagRaw === '' ? '(unset; default on)' : flagRaw
})
if (flagRaw === '0' || flagRaw === 'false' || flagRaw === 'no' || flagRaw === 'off') {
console.info('[auto-migrate] skipped: env flag disabled', { sessionId: session.id })
return session
}
if (!md || md.flavor !== 'cursor') {
console.info('[auto-migrate] skipped: not a cursor session', { sessionId: session.id, flavor: md?.flavor ?? null })
return session
}
if (md.cursorSessionProtocol === 'acp') {
console.info('[auto-migrate] skipped: already ACP', { sessionId: session.id })
return session
}
if (typeof md.cursorSessionId !== 'string' || md.cursorSessionId.length === 0) {
console.info('[auto-migrate] skipped: no cursorSessionId', { sessionId: session.id })
return session
}
console.info('[auto-migrate] starting transplant', { sessionId: session.id, cursorSessionId: md.cursorSessionId })
// UX A++: surface the migration to the user via a banner in the
// web UI. We set `cursorMigrationState='in_progress'` on the row
// BEFORE the long-running transplant; the sessionCache.refresh()
// call emits a `session-updated` SSE event the web client uses to
// render the banner. The flag is cleared on success by the same
// metadata write that flips cursorSessionProtocol to 'acp' (see
// flipCursorSessionProtocolToAcp) so the banner disappears in the
// same render tick the chat re-renders as ACP — no flicker. On
// failure we clear the flag explicitly in the catch path below.
const flagSet = this.setCursorMigrationStateInProgress(session.id, namespace)
let bannerCleanupNeeded = flagSet
try {
const migrator = this.buildMigratorForRequest({})
// Codex #34 P2 (round 13): for inactive rows whose metadata
// still reads `lifecycleState === 'running'` (e.g. orphaned by
// a hub crash where the lifecycle transition didn't land), the
// migrator's preflight refuses with `running_refused` unless
// `forceArchiveRunning` is true. resumeSession's caller-side
// guard already ensured `session.active === false` (see the
// early-return above), so we know there's no runner to yank;
// the `running` lifecycle is stale metadata, not a live agent.
// This is exactly the stale-row case the sync-on-open path is
// meant to clean up — refusing here would defeat the whole
// point and silently fall back to the legacy launcher forever.
const outcome = await migrator.migrateOne(session, { forceArchiveRunning: true })
if (outcome.ok) {
console.info('[auto-migrate] success', {
sessionId: session.id,
cursorSessionId: md.cursorSessionId,
acpSessionId: outcome.acpSessionId,
durationMs: outcome.durationMs,
sourceRemoved: outcome.sourceRemoved,
replayNotifications: outcome.replayNotifications,
lastUsedModelPreserved: outcome.lastUsedModelPreserved
})
// Successful migration already cleared the flag atomically
// in flipCursorSessionProtocolToAcp; skip the cleanup write.
bannerCleanupNeeded = false
const refreshed = this.sessionCache.getSessionByNamespace(session.id, namespace)
if (refreshed) return refreshed
return session
}
// Soft fail — log and let the legacy launcher handle it.
console.info('[auto-migrate] legacy cursor session left as stream-json', {
sessionId: session.id,
reason: outcome.reason,
message: outcome.message
})
} catch (err) {
console.warn('[auto-migrate] unexpected error; falling back to legacy launcher', {
sessionId: session.id,
err: err instanceof Error ? err.message : String(err)
})
} finally {
// Failure or exception path: clear the in-progress banner flag
// so the user isn't left with a permanent "Upgrading..." banner
// even though we silently fell back to the legacy launcher.
if (bannerCleanupNeeded) {
this.clearCursorMigrationState(session.id, namespace)
}
}
return session
}
/**
* Set `metadata.cursorMigrationState='in_progress'` on the session row
* with a single retry on version-mismatch. Returns true if the flag was
* persisted (so the caller knows the finally-cleanup is required), false
* if the write failed entirely — in which case the banner never appeared
* and there's nothing to clean up. UX A++ helper for the auto-migrate
* banner; see maybeAutoMigrateLegacyCursorSession.
*/
private setCursorMigrationStateInProgress(sessionId: string, namespace: string): boolean {
for (let attempt = 0; attempt < 2; attempt += 1) {
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!latest?.metadata) return false
if (latest.metadata.cursorMigrationState === 'in_progress') return true
const nextMetadata = { ...latest.metadata, cursorMigrationState: 'in_progress' as const }
const result = this.store.sessions.updateSessionMetadata(
sessionId,
nextMetadata,
latest.metadataVersion,
namespace,
{ touchUpdatedAt: false }
)
if (result.result === 'success') {
this.sessionCache.refreshSession(sessionId)
return true
}
if (result.result === 'version-mismatch') {
this.sessionCache.refreshSession(sessionId)
continue
}
return false
}
return false
}
/**
* Clear `metadata.cursorMigrationState` (failure / exception cleanup).
* Idempotent; safe to call when the flag was never set. UX A++ helper.
*/
private clearCursorMigrationState(sessionId: string, namespace: string): void {
for (let attempt = 0; attempt < 2; attempt += 1) {
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!latest?.metadata) return
if (latest.metadata.cursorMigrationState === undefined) return
const nextMetadata: typeof latest.metadata = { ...latest.metadata }
delete nextMetadata.cursorMigrationState
const result = this.store.sessions.updateSessionMetadata(
sessionId,
nextMetadata,
latest.metadataVersion,
namespace,
{ touchUpdatedAt: false }
)
if (result.result === 'success') {
this.sessionCache.refreshSession(sessionId)
return
}
if (result.result === 'version-mismatch') {
this.sessionCache.refreshSession(sessionId)
continue
}
return
}
}
/** Inactive session with directory path but no agent thread and no prior user turn. */
private canFreshSpawnNeverStartedSession(session: Session, sessionId: string, namespace: string): boolean {
const metadata = session.metadata
@@ -655,11 +982,19 @@ export class SyncEngine {
}
}
const session = access.session
if (session.active) {
const initialSession = access.session
if (initialSession.active) {
return { type: 'success', sessionId: access.sessionId }
}
// tiann/hapi#824 — invisible, automatic, per-session ACP migration on
// first open. If this is a legacy stream-json Cursor session and we
// can safely migrate it right now (no other agent acp transport
// would block the post-migration ACP launcher), run the transplant
// synchronously before resuming. The user sees the regular session
// loading state for ~35s longer; the session opens as ACP.
const session = await this.maybeAutoMigrateLegacyCursorSession(initialSession, namespace)
const targetResult = this.resolveLocalResumeTarget(access.sessionId, namespace)
let flavor: AgentFlavor
let resumeToken: string | undefined
+304
View File
@@ -0,0 +1,304 @@
import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Session } from '@hapi/protocol/types'
import { Store } from '../store'
import { RpcRegistry } from '../socket/rpcRegistry'
import { SyncEngine } from './syncEngine'
/**
* tiann/hapi#824 — sync-on-open auto-migration tests.
*
* The helper `maybeAutoMigrateLegacyCursorSession` is the per-session gate
* that runs the transplant migrator inside `resumeSession` before the runner
* spawns. These tests cover the guard-clause matrix (env flag, metadata
* shape) and the happy-path metadata refresh.
*
* Per tiann/hapi#832: two `agent acp` processes coexist on the same host
* without conflict, and swear01's tiann/hapi#835 refactors the
* agent-acp-active lock into a cross-process refcount that explicitly
* supports this. So this helper does NOT pre-check the lock — the
* migrator's verify probe runs in an isolated HAPI_HOME (see
* verifyInTempHome) and the post-migration runner goes through the
* normal refcount-aware lock acquisition path.
*
* The migrator itself has its own 53-test unit suite (cursorLegacyMigrator
* .test.ts) and 3 integration tests against a real `agent acp`. Here we only
* verify that the SyncEngine triggers it under the right conditions and
* honours the env override.
*/
describe('SyncEngine.maybeAutoMigrateLegacyCursorSession', () => {
let store: Store
let engine: SyncEngine
let hapiHomeRoot: string
let originalEnvFlag: string | undefined
let originalHapiHome: string | undefined
function makeLegacySession(overrides: Partial<Session['metadata']> = {}): Session {
return {
id: 'session-auto-migrate-test',
machineId: 'machine-x',
createdAt: 1000,
updatedAt: 1000,
active: false,
model: null,
metadata: {
path: '/tmp/proj',
host: 'localhost',
flavor: 'cursor',
cursorSessionId: 'cursor-uuid-123',
cursorSessionProtocol: 'stream-json',
...overrides
}
} as unknown as Session
}
beforeEach(() => {
store = new Store(':memory:')
engine = new SyncEngine(store, {} as never, new RpcRegistry(), { broadcast() {} } as never)
hapiHomeRoot = mkdtempSync(join(tmpdir(), 'auto-migrate-test-'))
originalEnvFlag = process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE
originalHapiHome = process.env.HAPI_HOME
process.env.HAPI_HOME = hapiHomeRoot
delete process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE
})
afterEach(() => {
if (originalEnvFlag === undefined) {
delete process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE
} else {
process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE = originalEnvFlag
}
if (originalHapiHome === undefined) {
delete process.env.HAPI_HOME
} else {
process.env.HAPI_HOME = originalHapiHome
}
try { rmSync(hapiHomeRoot, { recursive: true, force: true }) } catch {}
})
async function callHelper(session: Session): Promise<Session> {
return await (engine as unknown as {
maybeAutoMigrateLegacyCursorSession(s: Session, ns: string): Promise<Session>
}).maybeAutoMigrateLegacyCursorSession(session, 'default')
}
function stubMigrator(outcome: { ok: boolean; reason?: string; message?: string }): { calls: Array<{ sessionId: string }> } {
const calls: Array<{ sessionId: string }> = []
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
migrateOne: async (s: Session) => {
calls.push({ sessionId: s.id })
if (outcome.ok) {
return {
ok: true,
sessionId: s.id,
legacyStoreDbPath: '/fake',
acpSessionDir: '/fake',
keptSource: false,
replayNotifications: 0,
lastUsedModel: null,
durationMs: 1
}
}
return {
ok: false,
sessionId: s.id,
reason: outcome.reason ?? 'internal_error',
message: outcome.message ?? 'stub failure',
durationMs: 1
}
}
})
return { calls }
}
it('skips non-cursor sessions without calling the migrator', async () => {
const session = makeLegacySession({ flavor: 'codex' as never })
const { calls } = stubMigrator({ ok: true })
const out = await callHelper(session)
expect(out).toBe(session)
expect(calls).toHaveLength(0)
})
it('skips already-ACP cursor sessions without calling the migrator', async () => {
const session = makeLegacySession({ cursorSessionProtocol: 'acp' as never })
const { calls } = stubMigrator({ ok: true })
const out = await callHelper(session)
expect(out).toBe(session)
expect(calls).toHaveLength(0)
})
it('skips cursor sessions with no cursorSessionId', async () => {
const session = makeLegacySession({ cursorSessionId: undefined })
const { calls } = stubMigrator({ ok: true })
const out = await callHelper(session)
expect(out).toBe(session)
expect(calls).toHaveLength(0)
})
it('respects HAPI_CURSOR_LEGACY_AUTO_MIGRATE=0', async () => {
process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE = '0'
const session = makeLegacySession()
const { calls } = stubMigrator({ ok: true })
const out = await callHelper(session)
expect(out).toBe(session)
expect(calls).toHaveLength(0)
})
it('respects HAPI_CURSOR_LEGACY_AUTO_MIGRATE=false', async () => {
process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE = 'false'
const session = makeLegacySession()
const { calls } = stubMigrator({ ok: true })
const out = await callHelper(session)
expect(out).toBe(session)
expect(calls).toHaveLength(0)
})
it('proceeds with migration regardless of the agent-acp-active lock state (refcount-aware after #835)', async () => {
// Per tiann/hapi#832/#835: multiple `agent acp` processes coexist on
// the same host. The auto-migrate helper does NOT pre-check the
// lock — the migrator's verify probe uses HAPI_HOME isolation and
// the post-migration runner uses the refcount-aware lock path.
const session = makeLegacySession()
const { calls } = stubMigrator({ ok: true })
const out = await callHelper(session)
expect(calls).toHaveLength(1)
expect(out).toBe(session)
})
it('falls back to the original session when migration fails (soft fail)', async () => {
const session = makeLegacySession()
const { calls } = stubMigrator({ ok: false, reason: 'target_already_exists', message: 'collision' })
const out = await callHelper(session)
expect(calls).toHaveLength(1)
expect(out).toBe(session)
})
it('swallows unexpected migrator errors and returns the original session', async () => {
const session = makeLegacySession()
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
migrateOne: async () => { throw new Error('boom') }
})
const out = await callHelper(session)
expect(out).toBe(session)
})
/**
* UX A++ (Codex #34 round 14): the helper sets
* `metadata.cursorMigrationState='in_progress'` BEFORE the long-running
* transplant, surfacing the migration to the web UI via the SSE
* session-updated event. The flag is cleared on failure (and atomically
* with the protocol flip on success). These tests pin the metadata
* transitions so the web banner has a stable contract.
*/
describe('UX A++ migration-in-progress banner flag', () => {
// Insert a real cursor legacy session into the store so the helper's
// metadata writes have something to update.
function insertLegacy(sessionId: string): Session {
const cache = (engine as unknown as { sessionCache: import('./sessionCache').SessionCache }).sessionCache
const persisted = cache.getOrCreateSession(
sessionId,
{
path: '/tmp/proj',
host: 'localhost',
flavor: 'cursor',
cursorSessionId: 'cursor-uuid-real',
cursorSessionProtocol: 'stream-json'
},
null,
'default'
)
return persisted
}
function getStoredMetadata(sessionId: string): Record<string, unknown> | undefined {
const store = (engine as unknown as { store: Store }).store
const row = store.sessions.getSession(sessionId)
if (!row) return undefined
return row.metadata as unknown as Record<string, unknown>
}
it('sets cursorMigrationState=in_progress before the migrator runs and clears it on failure', async () => {
const session = insertLegacy('session-flag-fail')
let observedFlagAtMigrate: unknown
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
migrateOne: async (s: Session) => {
observedFlagAtMigrate = getStoredMetadata(s.id)?.cursorMigrationState
return { ok: false, sessionId: s.id, reason: 'internal_error', message: 'stub fail', durationMs: 1 }
}
})
await callHelper(session)
expect(observedFlagAtMigrate).toBe('in_progress')
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBeUndefined()
})
it('sets cursorMigrationState=in_progress before the migrator runs and clears it on unexpected exception', async () => {
const session = insertLegacy('session-flag-exception')
let observedFlagAtMigrate: unknown
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
migrateOne: async (s: Session) => {
observedFlagAtMigrate = getStoredMetadata(s.id)?.cursorMigrationState
throw new Error('boom')
}
})
await callHelper(session)
expect(observedFlagAtMigrate).toBe('in_progress')
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBeUndefined()
})
it('on migrator success the helper does NOT clear the flag (the flip writer is responsible)', async () => {
// Pins the contract that the helper itself does NOT clear the
// flag on the ok branch — flipCursorSessionProtocolToAcp does
// (in the same write that flips the protocol). We stub a
// migrator that returns ok=true WITHOUT calling the flip path,
// so the flag should still be set after the helper returns.
const session = insertLegacy('session-flag-success-no-clear')
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
migrateOne: async (s: Session) => ({
ok: true,
sessionId: s.id,
legacyStoreDbPath: '/fake',
acpSessionDir: '/fake',
keptSource: false,
replayNotifications: 0,
lastUsedModel: null,
durationMs: 1
})
})
await callHelper(session)
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('in_progress')
})
it('flipCursorSessionProtocolToAcp clears cursorMigrationState in the same metadata write that flips protocol', () => {
const session = insertLegacy('session-flip-clears-flag')
const store = (engine as unknown as { store: Store }).store
const cache = (engine as unknown as { sessionCache: import('./sessionCache').SessionCache }).sessionCache
// Manually plant the flag (simulating the helper having run earlier).
const initial = store.sessions.getSession(session.id)!
const initialMeta = initial.metadata as unknown as Record<string, unknown>
store.sessions.updateSessionMetadata(
session.id,
{ ...initialMeta, cursorMigrationState: 'in_progress' } as unknown as typeof initial.metadata,
initial.metadataVersion,
'default',
{ touchUpdatedAt: false }
)
cache.refreshSession(session.id)
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('in_progress')
const result = engine.flipCursorSessionProtocolToAcp(session.id, 'default', null)
expect(result.result).toBe('success')
const after = getStoredMetadata(session.id)
// Both must be true in a SINGLE atomic metadata write.
expect(after?.cursorSessionProtocol).toBe('acp')
expect(after?.cursorMigrationState).toBeUndefined()
})
})
})
+39
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import {
CreateOrLoadMachineRequestSchema,
CreateOrLoadSessionRequestSchema,
CursorMigrateToAcpRequestSchema,
PROTOCOL_VERSION
} from '@hapi/protocol'
import { getConfiguration } from '../../configuration'
@@ -198,6 +199,44 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<Cl
return c.json({ messages })
})
app.post('/sessions/:id/migrate-to-acp', async (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const sessionId = c.req.param('id')
const namespace = c.get('namespace')
const resolved = resolveSessionForNamespace(engine, sessionId, namespace)
if (!resolved.ok) {
return c.json({ error: resolved.error }, resolved.status)
}
// Codex #34 P2 (round 13): mirror the sessions.ts route hardening —
// distinguish "no body" from "malformed JSON". A silent fallback to
// {} would run the migration with destructive defaults even when
// the operator's intended body was mangled in transit.
const rawBody = await c.req.text()
let body: unknown = {}
if (rawBody.trim().length > 0) {
try {
body = JSON.parse(rawBody)
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
}
const parsed = CursorMigrateToAcpRequestSchema.safeParse(body ?? {})
if (!parsed.success) {
return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400)
}
const outcome = await engine.migrateLegacyCursorSession(resolved.sessionId, namespace, parsed.data)
const status = outcome.ok ? 200
: outcome.reason === 'already_acp' || outcome.reason === 'not_cursor_session' || outcome.reason === 'no_cursor_session_id' ? 409
: outcome.reason === 'running_refused' ? 409
: outcome.reason === 'target_already_exists' ? 409
: outcome.reason === 'no_legacy_store_on_disk' ? 404
: 500
return c.json(outcome, status)
})
app.post('/machines', async (c) => {
const engine = getSyncEngine()
if (!engine) {
+49
View File
@@ -1,4 +1,5 @@
import {
CursorMigrateToAcpRequestSchema,
DeleteUploadRequestSchema,
getPermissionModesForFlavor,
isPermissionModeAllowedForFlavor,
@@ -303,6 +304,54 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
return c.json({ ok: true })
})
app.post('/sessions/:id/migrate-to-acp', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine)
if (sessionResult instanceof Response) {
return sessionResult
}
// Codex #34 P2 (round 13): `c.req.json().catch(() => ({}))` silently
// converts malformed JSON into an empty object — which then passes
// CursorMigrateToAcpRequestSchema (all fields optional) and runs
// the migration with DESTRUCTIVE defaults (keepSource defaults to
// remove-after-flip). An operator who intended `{"keepSource": true}`
// but sent a truncated body would see the legacy store removed
// anyway. Distinguish "no body at all" (defaults are fine) from
// "malformed JSON" (reject with 400).
const rawBody = await c.req.text()
let body: unknown = {}
if (rawBody.trim().length > 0) {
try {
body = JSON.parse(rawBody)
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
}
const parsed = CursorMigrateToAcpRequestSchema.safeParse(body ?? {})
if (!parsed.success) {
return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400)
}
const namespace = c.get('namespace')
const outcome = await engine.migrateLegacyCursorSession(
sessionResult.sessionId,
namespace,
parsed.data
)
const status = outcome.ok ? 200
: outcome.reason === 'already_acp' || outcome.reason === 'not_cursor_session' || outcome.reason === 'no_cursor_session_id' ? 409
: outcome.reason === 'running_refused' ? 409
: outcome.reason === 'target_already_exists' ? 409
: outcome.reason === 'no_legacy_store_on_disk' ? 404
: 500
return c.json(outcome, status)
})
app.post('/sessions/:id/switch', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
+34
View File
@@ -147,6 +147,40 @@ export const RenameSessionRequestSchema = z.object({
export type RenameSessionRequest = z.infer<typeof RenameSessionRequestSchema>
/** Per-session legacy stream-json → ACP migrator request. See tiann/hapi#824. */
export const CursorMigrateToAcpRequestSchema = z.object({
/** Skip removing the legacy ~/.cursor/chats source store.db even after verify passes. */
keepSource: z.boolean().optional(),
/** Allow migrating a session whose lifecycleState === 'running' by archiving it first. */
forceArchiveRunning: z.boolean().optional(),
/** Skip the verify-by-prompt step (session/load alone is run). */
skipVerify: z.boolean().optional()
})
export type CursorMigrateToAcpRequest = z.infer<typeof CursorMigrateToAcpRequestSchema>
export type CursorMigrateOutcome =
| { ok: true; sessionId: string; acpSessionId: string; replayNotifications: number; durationMs: number; lastUsedModelPreserved: string | null; sourceRemoved: boolean }
| { ok: false; sessionId: string; reason: CursorMigrateRefusalReason; message: string; durationMs: number }
export type CursorMigrateRefusalReason =
| 'not_cursor_session'
| 'already_acp'
| 'running_refused'
| 'no_cursor_session_id'
| 'no_legacy_store_on_disk'
| 'target_already_exists'
| 'verify_load_failed'
| 'verify_prompt_failed'
| 'metadata_write_failed'
| 'archive_failed'
| 'lock_release_timeout'
| 'acp_transport_active'
| 'session_resumed_during_migrate'
| 'legacy_store_modified_during_migrate'
| 'cross_host_session'
| 'internal_error'
export const UploadFileRequestSchema = z.object({
filename: z.string().min(1).max(255),
content: z.string().min(1),
+1
View File
@@ -39,6 +39,7 @@ export const MetadataSchema = z.object({
opencodeSessionId: z.string().optional(),
cursorSessionId: z.string().optional(),
cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional(),
cursorMigrationState: z.enum(['in_progress']).optional(),
kimiSessionId: z.string().optional(),
tools: z.array(z.string()).optional(),
slashCommands: z.array(z.string()).optional(),
+50
View File
@@ -25,6 +25,8 @@ import type {
} from '@/types/api'
import type {
CodexModelsResponse,
CursorMigrateOutcome,
CursorMigrateToAcpRequest,
CursorModelsResponse,
DeleteUploadResponse,
FileReadResponse,
@@ -426,6 +428,54 @@ export class ApiClient {
)
}
/**
* Migrate a legacy stream-json Cursor session to ACP. See tiann/hapi#824.
*
* Refusals (e.g. running session, missing on-disk store, target collision)
* are returned as structured `{ok: false, reason, message}` outcomes
* rather than thrown - the UI surfaces the reason to the operator and the
* underlying state on disk is unchanged.
*
* 401s trigger the same onUnauthorized refresh path as the shared
* `request()` helper so an expired JWT silently re-auths instead of
* hard-failing the migration dialog (Codex review #34 P2).
*/
async migrateCursorSessionToAcp(sessionId: string, body: CursorMigrateToAcpRequest = {}): Promise<CursorMigrateOutcome> {
const path = `/api/sessions/${encodeURIComponent(sessionId)}/migrate-to-acp`
const tryOnce = async (overrideToken: string | null): Promise<Response> => {
const headers = new Headers({ 'content-type': 'application/json' })
const liveToken = this.getToken ? this.getToken() : null
const authToken = overrideToken ?? liveToken ?? this.token
if (authToken) {
headers.set('authorization', `Bearer ${authToken}`)
}
return fetch(this.buildUrl(path), { method: 'POST', headers, body: JSON.stringify(body) })
}
let res = await tryOnce(null)
if (res.status === 401 && this.onUnauthorized) {
const refreshed = await this.onUnauthorized()
if (refreshed) {
this.token = refreshed
res = await tryOnce(refreshed)
}
}
if (res.status === 401) {
throw new Error('Session expired. Please sign in again.')
}
const text = await res.text()
let parsed: CursorMigrateOutcome | null = null
try {
parsed = text ? JSON.parse(text) as CursorMigrateOutcome : null
} catch {
parsed = null
}
if (parsed && typeof parsed === 'object' && 'ok' in parsed) {
return parsed
}
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`)
}
async switchSession(sessionId: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/switch`, {
method: 'POST',
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { I18nProvider } from '@/lib/i18n-context'
import { CursorMigrationBanner, isCursorMigrationInProgress } from './CursorMigrationBanner'
import type { Metadata } from '@/types/api'
function renderWithProviders(ui: React.ReactElement) {
return render(<I18nProvider>{ui}</I18nProvider>)
}
afterEach(() => {
cleanup()
})
function metadata(partial: Partial<Metadata> = {}): Metadata {
return {
path: '/tmp/x',
host: 'localhost',
...partial
} as Metadata
}
describe('isCursorMigrationInProgress', () => {
it('returns true when flag is in_progress', () => {
expect(isCursorMigrationInProgress(metadata({ cursorMigrationState: 'in_progress' }))).toBe(true)
})
it('returns false when flag is undefined', () => {
expect(isCursorMigrationInProgress(metadata())).toBe(false)
})
it('returns false when metadata is null', () => {
expect(isCursorMigrationInProgress(null)).toBe(false)
})
it('returns false when metadata is undefined', () => {
expect(isCursorMigrationInProgress(undefined)).toBe(false)
})
})
describe('CursorMigrationBanner', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'localStorage', {
value: {
getItem: vi.fn(() => 'en'),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: vi.fn(() => null),
length: 0
},
configurable: true
})
})
it('renders the banner when cursorMigrationState is in_progress', () => {
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorMigrationState: 'in_progress' })} />)
expect(screen.getByTestId('cursor-migration-banner')).toBeInTheDocument()
expect(screen.getByText('Upgrading Cursor session')).toBeInTheDocument()
expect(screen.getByText(/safer ACP protocol/)).toBeInTheDocument()
})
it('does not render when cursorMigrationState is undefined', () => {
renderWithProviders(<CursorMigrationBanner metadata={metadata()} />)
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
})
it('does not render when metadata is null', () => {
renderWithProviders(<CursorMigrationBanner metadata={null} />)
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
})
it('does not render when metadata is undefined', () => {
renderWithProviders(<CursorMigrationBanner metadata={undefined} />)
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
})
it('does not render when migration is complete (cursorSessionProtocol is acp but no in_progress flag)', () => {
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorSessionProtocol: 'acp' })} />)
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
})
it('uses role=status and aria-live=polite for screen-reader accessibility', () => {
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorMigrationState: 'in_progress' })} />)
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-live', 'polite')
})
})
@@ -0,0 +1,58 @@
/**
* CursorMigrationBanner
*
* Surfaces the in-progress automatic legacy-stream-json → ACP migration to
* the user, so the 15-20s "dark wait" while the migrator transplants the
* store.db, spawns `agent acp`, replays notifications, and tears down the
* verify probe doesn't read as "broken / nothing is happening".
*
* Visibility contract:
* - Renders when `session.metadata.cursorMigrationState === 'in_progress'`
* - Hub flips the flag → SSE `session-updated` → React Query cache → this
* re-renders within milliseconds (no client-side polling needed; the
* hub's session-updated channel is already real-time).
* - 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.
* - On failure / exception the hub clears the flag explicitly in the
* auto-migrate helper's finally, so the banner never gets stuck.
*
* Deliberately minimal — no fake progress bar (we don't have phase data and
* a fake percentage would lie); just an indeterminate spinner + a short
* explanation. UX A++ design notes are in PR #34's body.
*/
import type { Metadata } from '@/types/api'
import { useTranslation } from '@/lib/use-translation'
export function isCursorMigrationInProgress(metadata: Metadata | undefined | null): boolean {
if (!metadata) return false
return metadata.cursorMigrationState === 'in_progress'
}
export function CursorMigrationBanner({ metadata }: { metadata: Metadata | undefined | null }) {
const { t } = useTranslation()
if (!isCursorMigrationInProgress(metadata)) {
return null
}
return (
<div className="px-3 pt-3" data-testid="cursor-migration-banner">
<div
role="status"
aria-live="polite"
className="mx-auto flex w-full max-w-content items-start gap-3 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-text)]"
>
<span
aria-hidden="true"
className="mt-0.5 inline-block h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
<div className="min-w-0 flex-1">
<div className="font-medium">{t('session.cursorMigration.banner.title')}</div>
<div className="text-xs text-[var(--app-hint)]">
{t('session.cursorMigration.banner.body')}
</div>
</div>
</div>
</div>
)
}
+3
View File
@@ -30,6 +30,7 @@ import { useHappyRuntime } from '@/lib/assistant-runtime'
import { createAttachmentAdapter } from '@/lib/attachmentAdapter'
import { useTranslation } from '@/lib/use-translation'
import { SessionHeader } from '@/components/SessionHeader'
import { CursorMigrationBanner } from '@/components/CursorMigrationBanner'
import { TeamPanel } from '@/components/TeamPanel'
import { usePlatform } from '@/hooks/usePlatform'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
@@ -982,6 +983,8 @@ function SessionChatInner(props: SessionChatProps) {
}}
/>
<CursorMigrationBanner metadata={props.session.metadata} />
{props.session.teamState && (
<TeamPanel teamState={props.session.teamState} />
)}
+2
View File
@@ -118,6 +118,8 @@ export default {
'session.time.importedFromCodex.minutesAgo': 'imported from Codex {n}m ago',
'session.time.importedFromCodex.hoursAgo': 'imported from Codex {n}h ago',
'session.time.importedFromCodex.daysAgo': 'imported from Codex {n}d ago',
'session.cursorMigration.banner.title': 'Upgrading Cursor session',
'session.cursorMigration.banner.body': 'Switching this legacy chat to the safer ACP protocol. This takes 15-20 seconds for sessions with long history; your conversation will resume automatically and any draft text will survive.',
// Session inactive
'session.inactive.autoResume': 'This session is inactive. Send a message to resume.',
+3
View File
@@ -118,6 +118,8 @@ export default {
'session.time.importedFromCodex.minutesAgo': '{n} 分钟前从codex客户端导入',
'session.time.importedFromCodex.hoursAgo': '{n} 小时前从codex客户端导入',
'session.time.importedFromCodex.daysAgo': '{n} 天前从codex客户端导入',
'session.cursorMigration.banner.title': '正在升级 Cursor 会话',
'session.cursorMigration.banner.body': '正在将此旧版会话切换到更安全的 ACP 协议。历史较长的会话需要 15-20 秒;对话会自动恢复,已输入但未发送的草稿不会丢失。',
// Session inactive
'session.inactive.autoResume': '此会话已停止。发送消息即可恢复。',
@@ -164,6 +166,7 @@ export default {
'dialog.delete.description': '确定要删除 "{name}" 吗?此操作无法撤销。',
'dialog.delete.confirm': '删除',
'dialog.delete.confirming': '删除中…',
'dialog.error.default': '操作失败,请重试。',
// Session export
+1
View File
@@ -40,6 +40,7 @@ export type {
AgentState,
AttachmentMetadata,
CodexCollaborationMode,
Metadata,
PermissionMode,
Machine,
RunnerState,