From 55d1bbb7bd946ecf21f5625aeabe98cb3f90e1a7 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:57:55 +0100 Subject: [PATCH] feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP (#844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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///store.db` into `~/.cursor/acp-sessions//`, 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//` 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 `/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 * 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 --------- Co-authored-by: Cursor --- bun.lock | 2 + hub/src/cursor/acpVerifyProbe.test.ts | 358 ++++++ hub/src/cursor/acpVerifyProbe.ts | 671 ++++++++++ hub/src/cursor/cursorLegacyMigrator.test.ts | 1139 +++++++++++++++++ hub/src/cursor/cursorLegacyMigrator.ts | 1043 +++++++++++++++ .../cursorLegacyMigratorIntegration.test.ts | 277 ++++ .../fixtures/buildSyntheticLegacyStore.ts | 68 + hub/src/store/index.ts | 17 +- hub/src/sync/syncEngine.ts | 341 ++++- hub/src/sync/syncEngineAutoMigrate.test.ts | 304 +++++ hub/src/web/routes/cli.ts | 39 + hub/src/web/routes/sessions.ts | 49 + shared/src/apiTypes.ts | 34 + shared/src/schemas.ts | 1 + web/src/api/client.ts | 50 + .../components/CursorMigrationBanner.test.tsx | 86 ++ web/src/components/CursorMigrationBanner.tsx | 58 + web/src/components/SessionChat.tsx | 3 + web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 3 + web/src/types/api.ts | 1 + 21 files changed, 4539 insertions(+), 7 deletions(-) create mode 100644 hub/src/cursor/acpVerifyProbe.test.ts create mode 100644 hub/src/cursor/acpVerifyProbe.ts create mode 100644 hub/src/cursor/cursorLegacyMigrator.test.ts create mode 100644 hub/src/cursor/cursorLegacyMigrator.ts create mode 100644 hub/src/cursor/cursorLegacyMigratorIntegration.test.ts create mode 100644 hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts create mode 100644 hub/src/sync/syncEngineAutoMigrate.test.ts create mode 100644 web/src/components/CursorMigrationBanner.test.tsx create mode 100644 web/src/components/CursorMigrationBanner.tsx diff --git a/bun.lock b/bun.lock index 94c5fb52..0bcd2d04 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/hub/src/cursor/acpVerifyProbe.test.ts b/hub/src/cursor/acpVerifyProbe.test.ts new file mode 100644 index 00000000..4deff345 --- /dev/null +++ b/hub/src/cursor/acpVerifyProbe.test.ts @@ -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((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/.local/bin/.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) + }) +}) diff --git a/hub/src/cursor/acpVerifyProbe.ts b/hub/src/cursor/acpVerifyProbe.ts new file mode 100644 index 00000000..f7066589 --- /dev/null +++ b/hub/src/cursor/acpVerifyProbe.ts @@ -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 + * `/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 } + | { ok: false; error: { code: number; message: string; data?: unknown } } + +export type AcpNotification = { + method: string + params: Record +} + +/** Subset of session/load response useful to the migrator. */ +export interface AcpLoadOutcome { + response: AcpRpcResponse + notificationCount: number + notificationKinds: Record + 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 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 { + 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((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 { + 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 { + 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 = {} + for (const n of drained) { + const u = (n.params as Record)?.update as Record | 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 { + 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 { + return this.send('session/set_model', { sessionId: params.sessionId, modelId: params.modelId }, timeoutMs) + } + + // --------------------------------------------------------------------- + + private send(method: string, params: unknown, timeoutMs?: number): Promise { + 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((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 + try { + msg = JSON.parse(line) as Record + } 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 + 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 }) + } 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 + }) + } + } + } + + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/hub/src/cursor/cursorLegacyMigrator.test.ts b/hub/src/cursor/cursorLegacyMigrator.test.ts new file mode 100644 index 00000000..70f262af --- /dev/null +++ b/hub/src/cursor/cursorLegacyMigrator.test.ts @@ -0,0 +1,1139 @@ +/** + * Unit tests for the legacy stream-json → ACP migrator (tiann/hapi#824). + * + * Strategy: + * - Real filesystem in a per-test tmpdir (cheaper than mocking node:fs) + * - Real bun:sqlite for the synthetic store fixture (the migrator reads + * meta.lastUsedModel directly) + * - MOCK agent acp via the createProbe dependency injection point. The + * mock probe records calls and returns scripted responses so we can + * exercise every branch without spawning a child process. + * - MOCK the hapi.db write via the updateSessionAfterMigrate dep. + * + * Covers: + * - happy path: cp + verify + flip + rm + * - --keep-source preserves the source after success + * - lastUsedModel round-trip + * - refusals: not a cursor session, already on ACP, no cursor session id, + * missing on-disk store, ACP target already exists, running without + * force-archive + * - rollback on session/load failure + * - rollback on session/prompt failure + * - rollback on metadata write failure + * - --force-archive-then-migrate archives a running session before proceeding + * - skipVerify path (load + prompt both skipped, no probe spawned) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import type { Metadata } from '@hapi/protocol/schemas' +import type { Session } from '@hapi/protocol/types' +import type { AcpRpcResponse } from './acpVerifyProbe' +import { CursorLegacyMigrator, findLegacyChatStore, readLegacyMetaLastUsedModel } from './cursorLegacyMigrator' +import { buildSyntheticLegacyStore } from './fixtures/buildSyntheticLegacyStore' +/* ---------- mock probe ---------- */ + +interface ScriptedProbe { + initializeResponse: AcpRpcResponse + loadResponse: AcpRpcResponse + loadNotificationCount: number + promptResponse: AcpRpcResponse + started: boolean + stopped: boolean + initializeCalls: number + loadCalls: number + promptCalls: number +} + +function ok(result: Record = {}): AcpRpcResponse { + return { ok: true, result } +} + +function err(message: string, code: number = -32602): AcpRpcResponse { + return { ok: false, error: { code, message } } +} + +function makeMockProbe(overrides: Partial = {}): ScriptedProbe & { + start(): void + stop(): Promise + initialize(): Promise + loadSession(): Promise<{ response: AcpRpcResponse; notificationCount: number; notificationKinds: Record; durationMs: number }> + prompt(): Promise<{ response: AcpRpcResponse; durationMs: number }> + setModel?(): Promise +} { + const state: ScriptedProbe = { + initializeResponse: ok({ protocolVersion: 1 }), + loadResponse: ok({ models: { availableModels: [], currentModelId: 'default[]' }, modes: { availableModes: [], currentModeId: 'agent' } }), + loadNotificationCount: 17, + promptResponse: ok({ stopReason: 'end_turn' }), + started: false, + stopped: false, + initializeCalls: 0, + loadCalls: 0, + promptCalls: 0, + ...overrides + } + return { + ...state, + start() { state.started = true }, + async stop() { state.stopped = true }, + async initialize() { + state.initializeCalls += 1 + return state.initializeResponse + }, + async loadSession() { + state.loadCalls += 1 + return { + response: state.loadResponse, + notificationCount: state.loadResponse.ok ? state.loadNotificationCount : 0, + notificationKinds: {}, + durationMs: 50 + } + }, + async prompt() { + state.promptCalls += 1 + return { response: state.promptResponse, durationMs: 30 } + }, + get started() { return state.started }, + get stopped() { return state.stopped }, + get initializeCalls() { return state.initializeCalls }, + get loadCalls() { return state.loadCalls }, + get promptCalls() { return state.promptCalls } + } as unknown as ScriptedProbe & ReturnType +} + +/* ---------- test harness ---------- */ + +interface Harness { + home: string + tmp: string + chatsDir: string + acpSessionsDir: string + /** Build a fake legacy session on disk; returns the on-disk path of store.db */ + placeLegacyStore: (cursorSessionId: string, opts?: { workspaceHash?: string; lastUsedModel?: string; name?: string }) => string + /** Make an in-memory Session row in the cursor flavor */ + makeSession: (overrides?: Partial) => Session + updateCalls: Array<{ sessionId: string; namespace: string; lastUsedModel: string | null }> + archiveCalls: string[] + probes: ReturnType[] + nextProbe: ReturnType | null +} + +function makeHarness(): Harness { + const home = mkdtempSync(join(tmpdir(), 'hapi-migrator-test-home-')) + const tmp = mkdtempSync(join(tmpdir(), 'hapi-migrator-test-tmp-')) + const chatsDir = join(home, '.cursor', 'chats') + const acpSessionsDir = join(home, '.cursor', 'acp-sessions') + mkdirSync(chatsDir, { recursive: true }) + mkdirSync(acpSessionsDir, { recursive: true }) + + const updateCalls: Harness['updateCalls'] = [] + const archiveCalls: Harness['archiveCalls'] = [] + const probes: Harness['probes'] = [] + return { + home, + tmp, + chatsDir, + acpSessionsDir, + updateCalls, + archiveCalls, + probes, + nextProbe: null, + placeLegacyStore(cursorSessionId, opts = {}) { + const wsh = opts.workspaceHash ?? `wsh-${Math.random().toString(36).slice(2, 10)}` + const dir = join(chatsDir, wsh, cursorSessionId) + mkdirSync(dir, { recursive: true }) + const storePath = join(dir, 'store.db') + buildSyntheticLegacyStore({ + path: storePath, + name: opts.name, + lastUsedModel: opts.lastUsedModel + }) + return storePath + }, + makeSession(overrides = {}) { + const sessionId = overrides.id ?? `sess-${Math.random().toString(36).slice(2, 8)}` + const cursorSessionId = (overrides.metadata as Metadata | undefined)?.cursorSessionId + ?? `cursor-${Math.random().toString(36).slice(2, 8)}` + const metadata: Metadata = { + path: '/workspace/example', + host: 'test-host', + flavor: 'cursor', + cursorSessionId, + ...(overrides.metadata ?? {}) + } + const base: Session = { + id: sessionId, + tag: sessionId, + namespace: 'default', + createdAt: 0, + updatedAt: 0, + seq: 0, + metadataVersion: 1, + agentStateVersion: 1, + metadata, + active: false, + model: null, + modelReasoningEffort: null, + effort: null, + permissionMode: undefined, + collaborationMode: null, + agentState: null, + todos: null, + todosUpdatedAt: null, + teamState: null, + teamStateUpdatedAt: null + } as unknown as Session + return { ...base, ...overrides, metadata } + } + } +} + +function cleanupHarness(h: Harness): void { + try { rmSync(h.home, { recursive: true, force: true }) } catch {} + try { rmSync(h.tmp, { recursive: true, force: true }) } catch {} +} + +function makeMigrator(h: Harness, probe: ReturnType | null, opts: { archiveSession?: (id: string) => Promise; updateOverride?: (sessionId: string, namespace: string, lastUsedModel: string | null) => { ok: true } | { ok: false; reason: 'version_mismatch_or_missing' } | { ok: false; reason: 'session_active' }; isAgentAcpTransportActive?: () => { active: boolean; holderPid: number | null }; getCurrentSession?: (sessionId: string, namespace: string) => { active: boolean; lifecycleState?: string; cursorSessionProtocol?: string } | null; acquireAcpActiveLock?: () => { release(): void } | null; checkpointLegacyStore?: (storeDbPath: string) => void } = {}): CursorLegacyMigrator { + return new CursorLegacyMigrator({}, { + homeDir: () => h.home, + hostName: () => 'h', // matches the test sessions' metadata.host + tmpDir: () => h.tmp, + now: () => 1_700_000_000_000, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createProbe: () => (probe ?? makeMockProbe()) as any, + awaitLockRelease: async () => true, + // Default to "no ACP transport active" so the migrator does not + // accidentally refuse on a real-world HAPI_HOME that happens to have + // a live lock during the test run (e.g. running on the operator's + // own machine while the dogfood agent is active). + isAgentAcpTransportActive: opts.isAgentAcpTransportActive ?? (() => ({ active: false, holderPid: null })), + // Default: acquire returns a no-op handle (we control the live + // lock check via isAgentAcpTransportActive instead). Tests + // simulating "lock unavailable" can return null here directly. + // Codex review #34 P2 v7. + acquireAcpActiveLock: opts.acquireAcpActiveLock ?? (() => ({ release() {} })), + // Default: no-op checkpoint. The real bun:sqlite checkpoint is + // exercised in integration tests; unit tests inject custom + // implementations to simulate post-checkpoint WAL growth. + // Codex review #34 P2 v8. + checkpointLegacyStore: opts.checkpointLegacyStore ?? (() => {}), + getCurrentSession: opts.getCurrentSession, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + archiveSession: opts.archiveSession ?? (async (id) => { h.archiveCalls.push(id) }), + updateSessionAfterMigrate: opts.updateOverride ?? ((sessionId, namespace, lastUsedModel) => { + h.updateCalls.push({ sessionId, namespace, lastUsedModel }) + return { ok: true } + }) + }) +} + +/* ---------- tests ---------- */ + +describe('findLegacyChatStore', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('finds the store.db under ~/.cursor/chats///', () => { + const storePath = h.placeLegacyStore('my-uuid', { workspaceHash: 'wsh-1' }) + const found = findLegacyChatStore('my-uuid', h.home) + expect(found).not.toBeNull() + expect(found?.storeDbPath).toBe(storePath) + expect(found?.workspaceHash).toBe('wsh-1') + }) + + it('returns null when the chat does not exist on disk', () => { + const found = findLegacyChatStore('non-existent-uuid', h.home) + expect(found).toBeNull() + }) + + it('returns null when ~/.cursor/chats itself does not exist', () => { + rmSync(join(h.home, '.cursor'), { recursive: true, force: true }) + const found = findLegacyChatStore('whatever', h.home) + expect(found).toBeNull() + }) + + it('scans multiple workspace-hash dirs to find the matching uuid', () => { + h.placeLegacyStore('uuid-a', { workspaceHash: 'wsh-a' }) + h.placeLegacyStore('uuid-b', { workspaceHash: 'wsh-b' }) + const found = findLegacyChatStore('uuid-b', h.home) + expect(found?.workspaceHash).toBe('wsh-b') + }) +}) + +describe('readLegacyMetaLastUsedModel', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('reads hex-encoded JSON meta record (legacy encoding)', () => { + const p = join(h.tmp, 'legacy.db') + buildSyntheticLegacyStore({ path: p, lastUsedModel: 'composer-2.5', name: 'chat 1', metaEncoding: 'hex' }) + const out = readLegacyMetaLastUsedModel(p) + expect(out).not.toBeNull() + expect(out?.lastUsedModel).toBe('composer-2.5') + expect(out?.name).toBe('chat 1') + }) + + it('reads raw JSON meta record (newer encoding)', () => { + const p = join(h.tmp, 'newer.db') + buildSyntheticLegacyStore({ path: p, lastUsedModel: 'gpt-5.3-codex', metaEncoding: 'json' }) + const out = readLegacyMetaLastUsedModel(p) + expect(out?.lastUsedModel).toBe('gpt-5.3-codex') + }) + + it('returns null on a missing store', () => { + const out = readLegacyMetaLastUsedModel(join(h.tmp, 'does-not-exist.db')) + expect(out).toBeNull() + }) +}) + +describe('CursorLegacyMigrator.migrateOne — refusals', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('refuses non-cursor sessions', async () => { + const session = h.makeSession({ metadata: { path: '/x', host: 'h', flavor: 'claude' } as Metadata }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('not_cursor_session') + }) + + it('refuses already-ACP sessions', async () => { + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'u', cursorSessionProtocol: 'acp' } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('already_acp') + }) + + it('refuses sessions with no cursorSessionId', async () => { + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: undefined as unknown as string } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('no_cursor_session_id') + }) + + it('refuses sessions whose lifecycleState is "running" without forceArchiveRunning', async () => { + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'u', lifecycleState: 'running' } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('running_refused') + }) + + it('refuses sessions where session.active=true even without lifecycleState (Codex #34 P2)', async () => { + const session = h.makeSession({ + active: true, + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'u' } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('running_refused') + }) + + it('refuses cursorSessionId values that fail basename validation (Codex #34 P2)', async () => { + for (const bad of ['../escape', 'has/slash', '.', '..', 'has\\backslash']) { + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: bad } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('no_cursor_session_id') + } + }) + + it('refuses sessions recorded on a different host (Codex #34 P2)', async () => { + const session = h.makeSession({ + metadata: { path: '/x', host: 'other-machine', flavor: 'cursor', cursorSessionId: 'a' } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('cross_host_session') + expect(out.message).toContain('other-machine') + }) + + it('refuses when the legacy on-disk store is missing', async () => { + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId: 'ghost-uuid' } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('no_legacy_store_on_disk') + }) + + it('refuses when another agent acp transport is live (Codex #34 P1 / P2 v7)', async () => { + const cursorSessionId = 'acp-active-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + // Codex review #34 P2 v7: lock acquisition is the primary + // refuse-on signal now. isAgentAcpTransportActive is read + // only to format the holder pid in the refusal message. + acquireAcpActiveLock: () => null, + isAgentAcpTransportActive: () => ({ active: true, holderPid: 12345 }) + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('acp_transport_active') + expect(out.message).toContain('12345') + // Source untouched. + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + }) + + it('refuses when ~/.cursor/acp-sessions// already exists (collision)', async () => { + const cursorSessionId = 'collision-uuid' + h.placeLegacyStore(cursorSessionId) + mkdirSync(join(h.acpSessionsDir, cursorSessionId), { recursive: true }) + writeFileSync(join(h.acpSessionsDir, cursorSessionId, 'meta.json'), '{}') + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, null).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (!out.ok) expect(out.reason).toBe('target_already_exists') + }) +}) + +describe('CursorLegacyMigrator.migrateOne — happy path', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('cp + verify + flip + rm in order; populates outcome', async () => { + const cursorSessionId = 'happy-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId, { lastUsedModel: 'composer-2.5' }) + const probe = makeMockProbe() + const session = h.makeSession({ + id: 'happy-sess', + metadata: { path: '/workspace/example', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, probe).migrateOne(session, {}) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.acpSessionId).toBe(cursorSessionId) + expect(out.replayNotifications).toBe(17) + expect(out.lastUsedModelPreserved).toBe('composer-2.5') + expect(out.sourceRemoved).toBe(true) + + // ACP location populated. + const acpStorePath = join(h.acpSessionsDir, cursorSessionId, 'store.db') + expect(existsSync(acpStorePath)).toBe(true) + const sidecarPath = join(h.acpSessionsDir, cursorSessionId, 'meta.json') + expect(existsSync(sidecarPath)).toBe(true) + const sidecarText = require('node:fs').readFileSync(sidecarPath, 'utf8') as string + const sidecarObj = JSON.parse(sidecarText) as Record + expect(sidecarObj.schemaVersion).toBe(1) + expect(sidecarObj.cwd).toBe('/workspace/example') + + // Legacy source removed. + expect(existsSync(sourceStore)).toBe(false) + + // updateSessionAfterMigrate invoked. + expect(h.updateCalls).toHaveLength(1) + expect(h.updateCalls[0].sessionId).toBe('happy-sess') + expect(h.updateCalls[0].lastUsedModel).toBe('composer-2.5') + + // Probe used. + expect(probe.started).toBe(true) + expect(probe.stopped).toBe(true) + expect(probe.initializeCalls).toBe(1) + expect(probe.loadCalls).toBe(1) + expect(probe.promptCalls).toBe(1) + }) + + it('passes HOME and HAPI_HOME isolated to the fakeHome into the verify probe (tiann/hapi#824)', async () => { + // tiann/hapi#824: the verify probe must inherit a private HAPI_HOME + // so its child `agent acp` registers its lock in an isolated tmp dir + // — NOT in the host's $HAPI_HOME where peer agents may hold the + // global agent-acp-active lock. Without this isolation the auto- + // migration path on a busy machine would always refuse with + // acp_transport_active, defeating its own purpose. + const cursorSessionId = 'isolated-home-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/iso', host: 'h', flavor: 'cursor', cursorSessionId } + }) + let capturedEnv: NodeJS.ProcessEnv | null = null + const migrator = new CursorLegacyMigrator({}, { + homeDir: () => h.home, + hostName: () => 'h', + tmpDir: () => h.tmp, + now: () => 1_700_000_000_000, + createProbe: (env) => { + capturedEnv = env + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return makeMockProbe() as any + }, + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + acquireAcpActiveLock: () => ({ release() {} }), + checkpointLegacyStore: () => {}, + getCurrentSession: () => null, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + archiveSession: async (id) => { h.archiveCalls.push(id) }, + updateSessionAfterMigrate: () => ({ ok: true }) + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(true) + expect(capturedEnv).not.toBeNull() + const env = capturedEnv as unknown as NodeJS.ProcessEnv + // The fakeHome path is generated inside verifyInTempHome under + // h.tmp; we don't need its exact value, only that HOME and + // HAPI_HOME point at the same path and that path is under our + // temp root (i.e. NOT the operator's real $HOME or $HAPI_HOME). + expect(typeof env.HOME).toBe('string') + expect(typeof env.HAPI_HOME).toBe('string') + expect(env.HOME).toBe(env.HAPI_HOME) + expect(env.HOME!.startsWith(h.tmp)).toBe(true) + // Defence in depth: the captured env must NOT leak the host's + // real HAPI_HOME (which could point at ~/.hapi or /tmp/hapi). + const realHapiHome = process.env.HAPI_HOME?.trim() || '' + if (realHapiHome.length > 0) { + expect(env.HAPI_HOME).not.toBe(realHapiHome) + } + }) + + it('passes metadata.homeDir (NOT deps.homeDir) as agentLookupHome to the verify probe (tiann/hapi#844)', async () => { + // tiann/hapi#844 upstream Codex Major: the default createProbe factory + // used `this.deps.homeDir()` for `agentLookupHome`, which on service- + // account hub deployments resolves to the hub user's $HOME — but the + // legacy store lives under the human user's home (metadata.homeDir). + // Earlier rounds wired `agentLookupHome` into the factory default + // but never threaded the resolved sourceHome through, so verification + // silently looked up `agent` under the wrong home and migrations + // fell back to legacy. The fix: createProbe takes a 2nd arg, and + // verifyInTempHome passes opts.sourceHome through. + const userHome = mkdtempSync(join(tmpdir(), 'hapi-migrator-user-home-')) + try { + const hubHome = h.home // distinct from userHome + const cursorSessionId = 'service-account-uuid' + const userChatsDir = join(userHome, '.cursor', 'chats', 'wsh-svc', cursorSessionId) + mkdirSync(userChatsDir, { recursive: true }) + const sourceStore = join(userChatsDir, 'store.db') + buildSyntheticLegacyStore({ path: sourceStore }) + const userAcpDir = join(userHome, '.cursor', 'acp-sessions') + mkdirSync(userAcpDir, { recursive: true }) + const session = h.makeSession({ + metadata: { + path: '/workspace/svc', + host: 'h', + flavor: 'cursor', + cursorSessionId, + homeDir: userHome + } + }) + let capturedAgentLookupHome: string | null = null + const migrator = new CursorLegacyMigrator({}, { + homeDir: () => hubHome, + hostName: () => 'h', + tmpDir: () => h.tmp, + now: () => 1_700_000_000_000, + createProbe: (_env, agentLookupHome) => { + capturedAgentLookupHome = agentLookupHome + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return makeMockProbe() as any + }, + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + acquireAcpActiveLock: () => ({ release() {} }), + checkpointLegacyStore: () => {}, + getCurrentSession: () => null, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + archiveSession: async (id) => { h.archiveCalls.push(id) }, + updateSessionAfterMigrate: () => ({ ok: true }) + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(true) + expect(capturedAgentLookupHome as unknown as string).toBe(userHome) + expect(capturedAgentLookupHome as unknown as string).not.toBe(hubHome) + } finally { + try { rmSync(userHome, { recursive: true, force: true }) } catch {} + } + }) + + it('falls back to deps.homeDir() for agentLookupHome when metadata.homeDir is absent (legacy session records)', async () => { + // Older session records may lack metadata.homeDir (the field was added + // in a later CLI rev). For those, the migrator falls back to + // this.deps.homeDir() for both the store lookup AND agentLookupHome. + const cursorSessionId = 'no-metadata-home-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/legacy', host: 'h', flavor: 'cursor', cursorSessionId } + }) + let capturedAgentLookupHome: string | null = null + const migrator = new CursorLegacyMigrator({}, { + homeDir: () => h.home, + hostName: () => 'h', + tmpDir: () => h.tmp, + now: () => 1_700_000_000_000, + createProbe: (_env, agentLookupHome) => { + capturedAgentLookupHome = agentLookupHome + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return makeMockProbe() as any + }, + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + acquireAcpActiveLock: () => ({ release() {} }), + checkpointLegacyStore: () => {}, + getCurrentSession: () => null, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + archiveSession: async (id) => { h.archiveCalls.push(id) }, + updateSessionAfterMigrate: () => ({ ok: true }) + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(true) + expect(capturedAgentLookupHome as unknown as string).toBe(h.home) + }) + + it('--keep-source preserves the legacy source after success', async () => { + const cursorSessionId = 'keep-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, { keepSource: true }) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.sourceRemoved).toBe(false) + expect(existsSync(sourceStore)).toBe(true) + }) + + it('skipVerify skips ONLY the session/prompt step (load is still verified)', async () => { + const cursorSessionId = 'skipverify-uuid' + h.placeLegacyStore(cursorSessionId) + const probe = makeMockProbe() + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, probe).migrateOne(session, { skipVerify: true }) + expect(out.ok).toBe(true) + // Codex review #34 P2: load must still run; only the prompt step is skipped. + expect(probe.started).toBe(true) + expect(probe.initializeCalls).toBe(1) + expect(probe.loadCalls).toBe(1) + expect(probe.promptCalls).toBe(0) + }) + + it('skipVerify still refuses on session/load failure', async () => { + const cursorSessionId = 'skipverify-loadfail-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const probe = makeMockProbe({ loadResponse: err('corrupted store') }) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, probe).migrateOne(session, { skipVerify: true }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_load_failed') + // Source untouched. + expect(existsSync(sourceStore)).toBe(true) + }) + + it('lastUsedModel = null when the legacy meta record does not carry one', async () => { + const cursorSessionId = 'no-model-uuid' + h.placeLegacyStore(cursorSessionId) // no lastUsedModel + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {}) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.lastUsedModelPreserved).toBeNull() + expect(h.updateCalls[0].lastUsedModel).toBeNull() + }) +}) + +describe('CursorLegacyMigrator.migrateOne — rollback paths', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('rolls back the ACP placement when session/load fails', async () => { + const cursorSessionId = 'load-fail-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const probe = makeMockProbe({ loadResponse: err('Session not found') }) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, probe).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_load_failed') + + // Acp dir not created (verify is in temp HOME, so the real acp-sessions + // location was never touched). + const acpStorePath = join(h.acpSessionsDir, cursorSessionId, 'store.db') + expect(existsSync(acpStorePath)).toBe(false) + // Source untouched. + expect(existsSync(sourceStore)).toBe(true) + // No hapi.db write. + expect(h.updateCalls).toHaveLength(0) + }) + + it('rolls back when session/prompt fails', async () => { + const cursorSessionId = 'prompt-fail-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const probe = makeMockProbe({ promptResponse: err('agent acp died') }) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, probe).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_prompt_failed') + expect(existsSync(sourceStore)).toBe(true) + expect(h.updateCalls).toHaveLength(0) + }) + + it('rolls back when the session is resumed mid-migration (Codex #34 P1)', async () => { + const cursorSessionId = 'resumed-mid-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + getCurrentSession: () => ({ active: true, lifecycleState: 'running' }) + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('session_resumed_during_migrate') + // ACP placement rolled back. + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + // Source untouched. + expect(existsSync(sourceStore)).toBe(true) + }) + + it('does NOT trip the resume-race recheck on stale lifecycleState=running after our own archive (Codex #34 P2 v5)', async () => { + // The force-archive flow archives synchronously (sets active=false) + // but the cleanup metadata write that flips lifecycleState to + // 'archived' may still be in-flight. The recheck must trust the + // active flag, not lifecycleState. + const cursorSessionId = 'stale-lifecycle-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + // Live runner at preflight (active=true), gets archived by us. + active: true, + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe(), { + archiveSession: async () => {}, + // After our archive: active=false (set by archive), but + // lifecycleState is still 'running' because the metadata + // cleanup write hasn't flushed yet. This should NOT refuse. + getCurrentSession: () => ({ active: false, lifecycleState: 'running' }) + }).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(h.updateCalls).toHaveLength(1) + }) + + it('still refuses the resume-race recheck when an EXTERNAL party set lifecycleState=running and wasActive=false (Codex #34 P2 v5)', async () => { + // If we did NOT archive and lifecycleState becomes running, + // someone else lifted the session back — that's a real race. + const cursorSessionId = 'external-resume-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + // Session NOT active/running at preflight — passes precheck. + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + getCurrentSession: () => ({ active: false, lifecycleState: 'running' }) + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('session_resumed_during_migrate') + }) + + it('rolls back when the legacy store.db is touched during the migration window (Codex #34 P1 v3)', async () => { + const cursorSessionId = 'fingerprint-divergence-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + // Override createProbe to mutate the legacy source between + // checkpoint and the resume-race recheck. + const probe = { + ...makeMockProbe(), + start() { /* no-op */ }, + async loadSession() { + // Simulate a brief resume that wrote new turns to the + // legacy store after our checkpoint. + require('node:fs').appendFileSync(sourceStore, Buffer.from([0x00, 0x01, 0x02])) + return { response: { ok: true as const, result: {} }, notificationCount: 0, notificationKinds: {}, durationMs: 1 } + }, + async initialize() { return { ok: true as const, result: {} } }, + async prompt() { return { response: { ok: true as const, result: {} }, durationMs: 1 } }, + async stop() {}, + getStderr() { return '' }, + getNotifications() { return [] }, + clearNotifications() {} + } as unknown as ReturnType + const out = await makeMigrator(h, probe, {}).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('legacy_store_modified_during_migrate') + expect(out.message).toMatch(/store\.db/) + // ACP placement rolled back. + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + // Source untouched. + expect(existsSync(sourceStore)).toBe(true) + }) + + it('refuses early when WAL has content immediately after the checkpoint (Codex #34 P2 v8)', async () => { + // Codex review #34 P2 v8: a TRUNCATE-mode wal_checkpoint zeros + // the WAL. If a writer lands between checkpoint return and the + // fingerprint capture, the WAL grows above zero and our baseline + // would be poisoned (we copy main-file-only). The migrator must + // refuse rather than accept the post-resume state. + const cursorSessionId = 'wal-grew-post-checkpoint-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + // Simulate a writer landing in the gap: the checkpoint + // "returned" but a WAL with content has appeared right + // after — exactly the race the bot flagged. + checkpointLegacyStore: (path) => { + require('node:fs').writeFileSync(`${path}-wal`, Buffer.from([0xff, 0xff, 0xff, 0xff])) + } + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('legacy_store_modified_during_migrate') + expect(out.message).toMatch(/between checkpoint and fingerprint/) + // No ACP placement, no source deletion. + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + expect(existsSync(sourceStore)).toBe(true) + }) + + it('rolls back when a WAL sidecar appears during the migration window (Codex #34 P1 v4)', async () => { + const cursorSessionId = 'wal-divergence-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + // Override createProbe to CREATE a store.db-wal sidecar where + // none existed at fingerprint time. This is what a brief resume + // does: opens the store, writes a frame to WAL. + const probe = { + ...makeMockProbe(), + start() {}, + async loadSession() { + require('node:fs').writeFileSync(`${sourceStore}-wal`, Buffer.from([0xff, 0xff, 0xff])) + return { response: { ok: true as const, result: {} }, notificationCount: 0, notificationKinds: {}, durationMs: 1 } + }, + async initialize() { return { ok: true as const, result: {} } }, + async prompt() { return { response: { ok: true as const, result: {} }, durationMs: 1 } }, + async stop() {}, + getStderr() { return '' }, + getNotifications() { return [] }, + clearNotifications() {} + } as unknown as ReturnType + const out = await makeMigrator(h, probe, {}).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('legacy_store_modified_during_migrate') + expect(out.message).toMatch(/store\.db-wal/) + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + }) + + it('rolls back when the atomic flip-time active check fires (Codex #34 P1 v2)', async () => { + // The migrator's earlier getCurrentSession recheck saw the session + // as inactive (default null), but the inner updateSessionAfterMigrate + // returns session_active — simulating the resume landing AFTER the + // recheck but inside the atomic flip. + const cursorSessionId = 'flip-time-active-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const migrator = makeMigrator(h, makeMockProbe(), { + updateOverride: () => ({ ok: false, reason: 'session_active' }) + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('session_resumed_during_migrate') + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + expect(existsSync(sourceStore)).toBe(true) + }) + + it('rolls back when a concurrent migration already flipped protocol to acp (Codex #34 P1)', async () => { + const cursorSessionId = 'concurrent-flip-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + getCurrentSession: () => ({ active: false, cursorSessionProtocol: 'acp' }) + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('already_acp') + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + expect(existsSync(sourceStore)).toBe(true) + }) + + it('rolls back ACP placement when hapi.db metadata write fails', async () => { + const cursorSessionId = 'meta-fail-uuid' + const sourceStore = h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const migrator = makeMigrator(h, makeMockProbe(), { + updateOverride: () => ({ ok: false, reason: 'version_mismatch_or_missing' }) + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('metadata_write_failed') + // The ACP placement was rolled back. + expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false) + // Source untouched. + expect(existsSync(sourceStore)).toBe(true) + }) +}) + +describe('CursorLegacyMigrator.migrateOne — force-archive-then-migrate', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('archives a running session first, then proceeds', async () => { + const cursorSessionId = 'force-archive-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + // active=true triggers the archive RPC. Codex review #34 + // P2 v6: the lifecycleState alone is no longer enough. + active: true, + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(true) + expect(h.archiveCalls).toEqual([session.id]) + }) + + it('does NOT call archiveSession on stale lifecycleState=running rows with no live runner (Codex #34 P2 v6)', async () => { + const cursorSessionId = 'stale-running-no-archive-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + // lifecycle says running but cache.active is false — the + // cleanup metadata write that flips 'running' → 'archived' + // was dropped (process crash before write). There is no + // live runner to archive. + active: false, + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(true) + if (!out.ok) return + // Critically: we did NOT call archive RPC. The metadata flip + // itself cleans up the stale lifecycle value. + expect(h.archiveCalls).toHaveLength(0) + }) + + it('does not archive when cross_host_session refusal would fire (Codex #34 P2 v2)', async () => { + const cursorSessionId = 'cross-host-no-archive-uuid' + h.placeLegacyStore(cursorSessionId) + const archiveCalls: string[] = [] + const session = h.makeSession({ + // active=true so wasActive would normally trigger archive + // — proves the cross-host check correctly precedes it. + active: true, + metadata: { path: '/x', host: 'other-machine', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe(), { + archiveSession: async (id) => { archiveCalls.push(id) } + }).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('cross_host_session') + expect(archiveCalls).toHaveLength(0) + }) + + it('reserves and releases the ACP lock around the mutation window (Codex #34 P2 v7)', async () => { + const cursorSessionId = 'acp-lock-lifecycle-uuid' + h.placeLegacyStore(cursorSessionId) + let releaseCount = 0 + let acquireCount = 0 + const session = h.makeSession({ + active: true, + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe(), { + acquireAcpActiveLock: () => { + acquireCount += 1 + return { release() { releaseCount += 1 } } + } + }).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(true) + // Lock was acquired exactly once and released exactly once even + // on the happy path. + expect(acquireCount).toBe(1) + expect(releaseCount).toBe(1) + }) + + it('releases the ACP lock even when migration refuses inside the locked window (Codex #34 P2 v7)', async () => { + const cursorSessionId = 'acp-lock-on-refusal-uuid' + h.placeLegacyStore(cursorSessionId) + // Pre-create the ACP target dir so target_already_exists fires + // INSIDE migrateOneWithLock (lock has been acquired by then). + const { mkdirSync } = require('node:fs') + mkdirSync(join(h.acpSessionsDir, cursorSessionId), { recursive: true }) + let releaseCount = 0 + const session = h.makeSession({ + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe(), { + acquireAcpActiveLock: () => ({ release() { releaseCount += 1 } }) + }).migrateOne(session, {}) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('target_already_exists') + expect(releaseCount).toBe(1) + }) + + it('does not archive when acp_transport_active refusal would fire (Codex #34 P2 v2 / P2 v7)', async () => { + const cursorSessionId = 'acp-active-no-archive-uuid' + h.placeLegacyStore(cursorSessionId) + const archiveCalls: string[] = [] + const session = h.makeSession({ + // active=true so wasActive would normally trigger archive + // — proves the acp_transport_active check correctly precedes it. + active: true, + metadata: { path: '/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const out = await makeMigrator(h, makeMockProbe(), { + // Codex review #34 P2 v7: lock acquisition is reserved BEFORE + // archive. Returning null here is the new way to express + // "another agent acp transport is live". + acquireAcpActiveLock: () => null, + isAgentAcpTransportActive: () => ({ active: true, holderPid: 42 }), + archiveSession: async (id) => { archiveCalls.push(id) } + }).migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('acp_transport_active') + expect(archiveCalls).toHaveLength(0) + }) + + it('surfaces archive failures as archive_failed', async () => { + const cursorSessionId = 'archive-throws-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + // Live runner — wasActive=true triggers the archive RPC, + // which throws and we surface archive_failed. Codex #34 P2 v6. + active: true, + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, lifecycleState: 'running' } + }) + const migrator = makeMigrator(h, makeMockProbe(), { + archiveSession: async () => { throw new Error('rpc gateway down') } + }) + const out = await migrator.migrateOne(session, { forceArchiveRunning: true }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('archive_failed') + expect(out.message).toContain('rpc gateway down') + }) +}) + +describe('CursorLegacyMigrator.migrateOne — telemetry', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('records a non-zero durationMs in every outcome', async () => { + const cursorSessionId = 'duration-uuid' + h.placeLegacyStore(cursorSessionId) + let t = 1_000_000 + const migrator = new CursorLegacyMigrator({}, { + homeDir: () => h.home, + hostName: () => 'h', + tmpDir: () => h.tmp, + now: () => (t += 25), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createProbe: () => makeMockProbe() as any, + awaitLockRelease: async () => true, + isAgentAcpTransportActive: () => ({ active: false, holderPid: null }), + archiveSession: async () => {}, + updateSessionAfterMigrate: () => ({ ok: true }) + }) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await migrator.migrateOne(session, {}) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.durationMs).toBeGreaterThan(0) + }) + + it('synthesized meta.json title comes from legacy meta name when present', async () => { + const cursorSessionId = 'title-uuid' + h.placeLegacyStore(cursorSessionId, { name: 'My Test Chat' }) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {}) + expect(out.ok).toBe(true) + const sidecarPath = join(h.acpSessionsDir, cursorSessionId, 'meta.json') + const sidecar = JSON.parse(require('node:fs').readFileSync(sidecarPath, 'utf8')) as Record + expect(sidecar.title).toBe('My Test Chat') + }) + + it('uses metadata.homeDir when present in preference to the hub HOME (Codex #34 P2)', async () => { + // Use a SEPARATE home dir than the harness's `h.home`, set on + // metadata. The legacy chat must live under the metadata-recorded + // home; the migrator should pick that path, not the hub HOME. + const ownerHome = mkdtempSync(join(tmpdir(), 'hapi-owner-home-')) + try { + const cursorSessionId = 'owner-home-uuid' + const ownerChatsDir = join(ownerHome, '.cursor', 'chats', 'wsh-owner', cursorSessionId) + mkdirSync(ownerChatsDir, { recursive: true }) + require('./fixtures/buildSyntheticLegacyStore').buildSyntheticLegacyStore({ + path: join(ownerChatsDir, 'store.db') + }) + // Hub HOME (h.home) has NO matching chat — only metadata.homeDir does. + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId, homeDir: ownerHome } as Metadata + }) + // Re-route the migrator's resolution: even though h.home points to + // a temp dir without the chat, the migrator should resolve under + // ownerHome (metadata.homeDir). + // The acp-sessions placement targets the SAME ownerHome since + // home is now resolved from metadata. + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {}) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(existsSync(join(ownerHome, '.cursor', 'acp-sessions', cursorSessionId, 'store.db'))).toBe(true) + } finally { + try { rmSync(ownerHome, { recursive: true, force: true }) } catch {} + } + }) + + it('cp leaves the placed store.db non-empty (sanity)', async () => { + const cursorSessionId = 'sanity-uuid' + h.placeLegacyStore(cursorSessionId) + const session = h.makeSession({ + metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId } + }) + const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {}) + expect(out.ok).toBe(true) + const acpStorePath = join(h.acpSessionsDir, cursorSessionId, 'store.db') + const st = statSync(acpStorePath) + expect(st.size).toBeGreaterThan(0) + }) +}) diff --git a/hub/src/cursor/cursorLegacyMigrator.ts b/hub/src/cursor/cursorLegacyMigrator.ts new file mode 100644 index 00000000..829a5208 --- /dev/null +++ b/hub/src/cursor/cursorLegacyMigrator.ts @@ -0,0 +1,1043 @@ +/** + * Legacy stream-json → ACP migrator (transplant strategy). + * + * See tiann/hapi#824 and docs/plans/2026-06-06-cursor-legacy-to-acp-spike.md + * for the spike that established the design. Short version: + * + * 1. cursor-agent's `agent acp` resolves `session/load` against + * ~/.cursor/acp-sessions//{store.db, meta.json}. + * 2. The legacy stream-json flow stores chats at + * ~/.cursor/chats///store.db. + * 3. The on-disk SQLite schema is byte-identical between the two stores + * (blobs content-addressed Merkle tree + meta key-value). + * 4. Therefore: cp legacy store.db into the ACP location, synthesize the + * meta.json sidecar, verify session/load works, then flip HAPI's + * cursorSessionProtocol = 'acp' so the existing cursorAcpRemoteLauncher + * (already in #799) picks the session up on next resume. + * + * Per-session sequence (cp + verify + flip + rm): + * a) cp legacy store.db -> ~/.cursor/acp-sessions//store.db + * b) write meta.json sidecar + * c) verify by spawning `agent acp` in a temp $HOME pointing at a *copy* + * of the transplanted store, doing initialize + session/load + (optional) + * a trivial session/prompt + * d) ONLY if verify passes: flip cursorSessionProtocol in hapi.db, set + * session.model from legacy meta record's lastUsedModel, and rm the + * legacy source. + * e) If verify fails: rm the new ~/.cursor/acp-sessions// entry, + * leave the legacy store untouched. + * + * The verify is staged in a temp $HOME so the verify session/prompt never + * pollutes the operator's real acp-sessions store. After verify passes, the + * real placement is just a fresh cp of the original legacy store.db. + * + * Per orchestrator policy (Q1 in the spike report): + * - cp + verify + rm; the rm is gated on observable success + * - --keep-source preserves the legacy store after success + * - --force-archive-then-migrate archives a running session first + * + * The fork-side launcher in cli/src/cursor/cursorAcpRemoteLauncher.ts already + * routes on metadata.cursorSessionProtocol === 'acp' (set by this migrator). + * No launcher change is required. + */ + +import { join, dirname } from 'node:path' +import { homedir, hostname, tmpdir } from 'node:os' +import { mkdtempSync, copyFileSync, writeFileSync, existsSync, mkdirSync, rmSync, rmdirSync, statSync, readdirSync, readFileSync, chmodSync } from 'node:fs' +import { Database } from 'bun:sqlite' + +import type { CursorMigrateOutcome, CursorMigrateRefusalReason } from '@hapi/protocol/apiTypes' +import type { Metadata } from '@hapi/protocol/schemas' +import type { Session } from '@hapi/protocol/types' +import { AcpVerifyProbe, tryAcquireAcpActiveLock, type AcpActiveLockHandle } from './acpVerifyProbe' + +/* ---------- types ---------- */ + +export interface CursorLegacyMigratorOptions { + keepSource?: boolean + forceArchiveRunning?: boolean + skipVerify?: boolean + /** Internal: max time to wait for a running session to release the store.db file lock after archive. */ + lockReleaseTimeoutMs?: number + /** Internal: max time the verify spawn is allowed in total. */ + verifyTimeoutMs?: number + /** Internal: the verify prompt body. Kept ultra-short to bound token cost. */ + verifyPromptText?: string +} + +export interface CursorLegacyMigratorDeps { + /** Resolve the operator's HOME dir. Override in tests. */ + homeDir?: () => string + /** + * Resolve the local hostname. Used to detect cross-host sessions + * (where the recorded `metadata.host` does not match the machine the + * hub is running on) — those sessions cannot be migrated because the + * legacy ~/.cursor/chats files exist on a different machine. + * Default: `process.env.HAPI_HOSTNAME || os.hostname()`. Codex + * review #34 P2. + */ + hostName?: () => string + /** + * Spawn factory for the verify probe. Override in tests to inject a mock probe. + * The second arg is the session-owner home (`metadata.homeDir`) resolved by + * `migrateOne`, which the default factory passes through to the probe as + * `agentLookupHome` so service-account hub deployments resolve `agent` under + * the human user's `~/.local/bin` rather than the hub user's. tiann/hapi#844 + * upstream Codex Major. + */ + createProbe?: (env: NodeJS.ProcessEnv, agentLookupHome: string) => AcpVerifyProbe + /** + * Optional escape hatch for the operator-driven CLI/REST flow that wants + * to reserve the global agent-acp-active lock for the entire migration + * window. Returns `null` if the lock is already held; the migrator then + * refuses with `acp_transport_active`. + * + * **Default in production: a no-op grant that never refuses.** The + * verify probe's child agent CLI now runs with an isolated HAPI_HOME + * (see verifyInTempHome), so the verify spawn does not race against any + * live ACP transport on the host. Refusing migration up-front when the + * host has other live transports — which is the common case on machines + * running peer agents — would make the auto-migration path unreachable + * for the operator who actually has 90+ legacy sessions to migrate. + * + * Tests can inject `() => null` to exercise the legacy refusal path. + * Operator CLI/REST callers can inject `tryAcquireAcpActiveLock(home)` + * if they want the conservative pre-isolation behavior back. + */ + acquireAcpActiveLock?: () => { release(): void } | null + /** + * Run `PRAGMA wal_checkpoint(TRUNCATE)` on the legacy store.db. + * Default: bun:sqlite checkpoint that refuses on busy=1 or partial + * apply. Tests can inject a no-op to simulate a writer landing + * between checkpoint return and the post-checkpoint fingerprint. + * Codex review #34 P2 v8. + */ + checkpointLegacyStore?: (storeDbPath: string) => void + /** Where to allocate the verify staging temp dir. Default: os.tmpdir(). */ + tmpDir?: () => string + /** Time source for telemetry. Default: Date.now. */ + now?: () => number + /** Optional hook to archive a running session. Required when forceArchiveRunning=true. */ + archiveSession?: (sessionId: string) => Promise + /** + * Wait for the archived session's store.db file lock to be released. + * Default: combined SQLite busy-probe + size-stability + minimum dwell + * time. The dwell is required because the hub cannot directly observe + * the runner subprocess exiting (it has no PID handle); without a + * minimum wait an idle runner with no SQLite write lock can pass the + * probe immediately while still in the middle of SIGTERM cleanup. + * Codex review #34 P1 v3. + */ + awaitLockRelease?: (storePath: string, timeoutMs: number) => Promise + /** + * Check whether ANY `agent acp` transport is registered as active in + * $HAPI_HOME/locks/agent-acp-active. Cursor's `agent` binary enforces + * single-instance semantics: spawning a second `agent acp` while one + * is live can SIGTERM the live one. Refuse the migration in that case + * because the verify-in-temp-HOME step would otherwise crash the + * operator's active Cursor ACP session. Codex review #34 P1. + * Default: read the lock dir under $HAPI_HOME (or tmpdir/hapi). + */ + isAgentAcpTransportActive?: () => { active: boolean; holderPid: number | null } + /** + * Re-read the latest session state from the hub cache, used to detect + * a resume that happened between preflight and the destructive steps. + * SyncEngine injects a real implementation; tests can inject a static + * sentinel. Codex review #34 P1: protects against the TOCTOU window + * where a session is resumed while migration is in flight. + */ + getCurrentSession?: (sessionId: string, namespace: string) => { active: boolean; lifecycleState?: string; cursorSessionProtocol?: string } | null + /** Logger sink. Default: silent. */ + logger?: { debug: (msg: string, ctx?: unknown) => void; info: (msg: string, ctx?: unknown) => void; warn: (msg: string, ctx?: unknown) => void; error: (msg: string, ctx?: unknown) => void } + /** Used to update hapi.db sessions.metadata.cursorSessionProtocol = 'acp' and session.model. */ + updateSessionAfterMigrate?: (sessionId: string, namespace: string, lastUsedModel: string | null) => UpdateAfterMigrateResult +} + +export type UpdateAfterMigrateResult = + | { ok: true } + | { ok: false; reason: 'version_mismatch_or_missing' } + | { ok: false; reason: 'session_active' } + +export interface LegacyStoreLocation { + workspaceHash: string + storeDbPath: string +} + +/* ---------- helpers ---------- */ + +const DEFAULT_VERIFY_PROMPT = 'Reply with exactly: ack' +const DEFAULT_VERIFY_TIMEOUT_MS = 120_000 +const DEFAULT_LOCK_RELEASE_TIMEOUT_MS = 5_000 +const DEFAULT_REPLAY_DRAIN_MS = 3_000 +const AUTH_FILES = ['cli-config.json', 'agent-cli-state.json', 'acp-config.json'] + +function noopLogger() { + return { debug() {}, info() {}, warn() {}, error() {} } +} + +function refusal(sessionId: string, reason: CursorMigrateRefusalReason, message: string, start: number, now: () => number): CursorMigrateOutcome { + return { ok: false, sessionId, reason, message, durationMs: now() - start } +} + +/* ---------- public API ---------- */ + +/** + * Resolve the on-disk legacy ~/.cursor/chats///store.db + * for the given cursorSessionId. Scans workspace-hash dirs because HAPI does + * not record the workspace-hash; it is hashed from the original cwd by Cursor + * and not exposed via the agent CLI. + */ +export function findLegacyChatStore(cursorSessionId: string, home: string): LegacyStoreLocation | null { + const chatsRoot = join(home, '.cursor', 'chats') + if (!existsSync(chatsRoot)) return null + let entries: string[] + try { + entries = readdirSync(chatsRoot) + } catch { + return null + } + for (const wsh of entries) { + const candidate = join(chatsRoot, wsh, cursorSessionId, 'store.db') + try { + const st = statSync(candidate) + if (st.isFile()) { + return { workspaceHash: wsh, storeDbPath: candidate } + } + } catch { + // not in this wsh; keep scanning + } + } + return null +} + +/** + * Read `lastUsedModel` (and chat name) from a legacy/ACP store.db's `meta` + * record. Returns null if the store cannot be opened or the meta record is + * missing. + * + * NOTE: the meta value is stored as a hex-encoded UTF-8 JSON blob in older + * cursor-agent versions and as a plain JSON string in newer ones. We try both. + */ +export function readLegacyMetaLastUsedModel(storeDbPath: string): { name?: string; lastUsedModel?: string } | null { + let metaDb: Database | null = null + try { + metaDb = new Database(storeDbPath, { readonly: true }) + const row = metaDb.prepare('SELECT cast(value as TEXT) as v FROM meta LIMIT 1').get() as { v?: string } | undefined + if (!row?.v) return null + const decoded = decodeMetaValue(row.v) + if (!decoded) return null + return { + name: typeof decoded.name === 'string' ? decoded.name : undefined, + lastUsedModel: typeof decoded.lastUsedModel === 'string' && decoded.lastUsedModel.trim().length > 0 ? decoded.lastUsedModel.trim() : undefined + } + } catch { + return null + } finally { + try { metaDb?.close() } catch {} + } +} + +function decodeMetaValue(value: string): Record | null { + // Try JSON first (newer ACP stores) + if (value.startsWith('{')) { + try { return JSON.parse(value) as Record } catch {} + } + // Otherwise try hex-encoded UTF-8 JSON (older legacy stores). + if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) { + try { + const buf = Buffer.from(value, 'hex') + const text = buf.toString('utf8') + if (text.startsWith('{')) { + return JSON.parse(text) as Record + } + } catch {} + } + return null +} + +/** + * UUID-ish pattern: a cursor session id MUST be a basename that cannot + * escape the chats/acp-sessions trees via path traversal. Codex review #34 + * P2: validate before any join(). + */ +const CURSOR_SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/ + +/** + * Pre-flight: return null if the session can be migrated; return a refusal + * outcome if it cannot. Pure: no side effects. + */ +export function preflightSession(session: Session | undefined, now: () => number, opts: { forceArchiveRunning?: boolean }): CursorMigrateOutcome | null { + const start = now() + if (!session) { + return refusal('(unknown)', 'internal_error', 'session not found', start, now) + } + const sessionId = session.id + const metadata = session.metadata + if (!metadata || metadata.flavor !== 'cursor') { + return refusal(sessionId, 'not_cursor_session', 'session.metadata.flavor must be "cursor"', start, now) + } + if (metadata.cursorSessionProtocol === 'acp') { + return refusal(sessionId, 'already_acp', 'session already runs over ACP; nothing to migrate', start, now) + } + if (typeof metadata.cursorSessionId !== 'string' || metadata.cursorSessionId.trim().length === 0) { + return refusal(sessionId, 'no_cursor_session_id', 'session.metadata.cursorSessionId is missing', start, now) + } + const trimmed = metadata.cursorSessionId.trim() + if (!CURSOR_SESSION_ID_RE.test(trimmed) || trimmed === '.' || trimmed === '..') { + return refusal(sessionId, 'no_cursor_session_id', `cursorSessionId '${trimmed}' fails basename validation`, start, now) + } + // Block both lifecycleState==='running' AND session.active (legacy rows + // may lack lifecycleState but still be active in the cache). Codex + // review #34 P2. + const lifecycle = typeof metadata.lifecycleState === 'string' ? metadata.lifecycleState : undefined + const isActive = lifecycle === 'running' || session.active === true + if (isActive && !opts.forceArchiveRunning) { + return refusal(sessionId, 'running_refused', 'session is active; archive first or pass forceArchiveRunning', start, now) + } + return null +} + +/* ---------- main entry ---------- */ + +export class CursorLegacyMigrator { + private readonly opts: Required> + private readonly deps: Required> + & Pick + + constructor(opts: CursorLegacyMigratorOptions, deps: CursorLegacyMigratorDeps) { + this.opts = { + lockReleaseTimeoutMs: opts.lockReleaseTimeoutMs ?? DEFAULT_LOCK_RELEASE_TIMEOUT_MS, + verifyTimeoutMs: opts.verifyTimeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS, + verifyPromptText: opts.verifyPromptText ?? DEFAULT_VERIFY_PROMPT + } + this.deps = { + homeDir: deps.homeDir ?? (() => homedir()), + hostName: deps.hostName ?? (() => process.env.HAPI_HOSTNAME?.trim() || hostname()), + createProbe: deps.createProbe ?? ((env, agentLookupHome) => new AcpVerifyProbe({ + env, + skipLockAcquire: true, + // tiann/hapi#844 upstream Codex Major: `migrateOne` resolves the + // legacy store under `metadata.homeDir` (the recorded session- + // owner home), so the probe MUST also look up `agent` under + // that same home. Earlier rounds plumbed `agentLookupHome` into + // the factory default using `this.deps.homeDir()` (the HUB's + // home), which falls back to the hub user's `~/.local/bin` on + // service-account deployments where the human installed Cursor + // under a different account. The caller (`verifyInTempHome`) + // now passes the resolved `sourceHome` through to keep the + // store and the binary discovery rooted in the same home. + agentLookupHome + })), + // Default: never refuse based on the global lock. The verify probe + // is isolated via HAPI_HOME override in verifyInTempHome, so the + // host's live ACP transports cannot block migration. Operator + // CLI/REST callers that want the pre-isolation conservative + // behavior can inject `() => tryAcquireAcpActiveLock(home)`. + acquireAcpActiveLock: deps.acquireAcpActiveLock ?? (() => ({ release() {} })), + checkpointLegacyStore: deps.checkpointLegacyStore ?? defaultCheckpointLegacyStore, + tmpDir: deps.tmpDir ?? (() => tmpdir()), + now: deps.now ?? (() => Date.now()), + awaitLockRelease: deps.awaitLockRelease ?? defaultAwaitLockRelease, + isAgentAcpTransportActive: deps.isAgentAcpTransportActive ?? defaultIsAgentAcpTransportActive, + // Default: cannot detect a resume — return null so the recheck + // is a no-op. SyncEngine injects a real impl that reads the + // session cache. Codex review #34 P1. + getCurrentSession: deps.getCurrentSession ?? (() => null), + logger: deps.logger ?? noopLogger(), + archiveSession: deps.archiveSession, + updateSessionAfterMigrate: deps.updateSessionAfterMigrate + } + } + + /** + * Migrate a single legacy cursor session in place. Side-effecting: writes + * to ~/.cursor/acp-sessions/, conditionally writes hapi.db (via injected + * updateSessionAfterMigrate), conditionally removes the legacy source. + */ + async migrateOne(session: Session, opts: CursorLegacyMigratorOptions): Promise { + const start = this.deps.now() + const log = this.deps.logger + + const pre = preflightSession(session, this.deps.now, { forceArchiveRunning: opts.forceArchiveRunning }) + if (pre) return pre + + // Type-narrow the metadata fields we use below. + const metadata = session.metadata as Metadata + const cursorSessionId = metadata.cursorSessionId as string + const cwd = metadata.path + + // ALL preconditions that could refuse must run BEFORE archive + // and BEFORE any other side effect. Otherwise a bulk run with + // --force-archive-running could kill a session that we then + // refuse to migrate (e.g. cross-host, acp_transport_active). + // Codex review #34 P2 v2. + + // Cross-host check: if the session was recorded on a different + // machine, its ~/.cursor/chats lives there, not here. + const recordedHost = typeof metadata.host === 'string' ? metadata.host.trim() : '' + const localHost = this.deps.hostName().trim() + if (recordedHost && localHost && recordedHost !== localHost) { + return refusal(session.id, 'cross_host_session', `session recorded host=${recordedHost} does not match local hub host=${localHost}; cannot migrate a session whose filesystem lives on a different machine`, start, this.deps.now) + } + + // ACP transport check + reservation. We used to do this as a + // point-in-time check followed by an internal lock acquire + // inside verifyInTempHome(). That left a gap: another agent + // acp could start between this check and the verify probe's + // spawn, and a --force-archive-running migration would kill the + // legacy session in that gap before refusing. Codex review + // #34 P2 v7: acquire the global agent-acp-active lock NOW and + // hold it across the entire mutation window. The verify probe + // is told skipLockAcquire=true so it inherits our hold. + let acpLock: { release(): void } | null = null + try { + acpLock = this.deps.acquireAcpActiveLock() + } catch (err) { + return refusal(session.id, 'internal_error', `agent-acp-active lock acquisition failed: ${err instanceof Error ? err.message : String(err)}`, start, this.deps.now) + } + if (acpLock === null) { + const holder = this.deps.isAgentAcpTransportActive() + return refusal(session.id, 'acp_transport_active', `another agent acp transport is registered active (holder pid=${holder.holderPid ?? '?'}); refusing to verify-migrate to avoid SIGTERMing the live ACP session — close active Cursor ACP sessions and retry`, start, this.deps.now) + } + try { + return await this.migrateOneWithLock(session, opts, start, metadata, cursorSessionId, cwd, log) + } finally { + acpLock.release() + } + } + + private async migrateOneWithLock( + session: Session, + opts: CursorLegacyMigratorOptions, + start: number, + metadata: Metadata, + cursorSessionId: string, + cwd: string, + log: NonNullable + ): Promise { + + // Resolve $HOME: prefer the recorded session owner's home from + // metadata.homeDir (populated by cli/src/agent/sessionFactory.ts) + // because the hub process may run under a service account whose + // HOME differs from the human-user account that created the + // Cursor session. Fall back to the hub's homeDir() when the + // metadata field is absent (older session records). + // Codex review #34 P2. + const recordedHome = typeof metadata.homeDir === 'string' && metadata.homeDir.trim().length > 0 + ? metadata.homeDir.trim() + : null + const home = recordedHome ?? this.deps.homeDir() + + // Locate the legacy store.db on disk BEFORE we archive. If the + // local filesystem has no such file, we have nothing to migrate + // and there's no reason to kill the session. + const legacy = findLegacyChatStore(cursorSessionId, home) + if (!legacy) { + return refusal(session.id, 'no_legacy_store_on_disk', `~/.cursor/chats/*/${cursorSessionId}/store.db not found under ${home}`, start, this.deps.now) + } + + // Pre-flight: refuse if the ACP target dir already exists. Also + // moved BEFORE archive to avoid killing a session whose target + // collision would refuse anyway. + const acpSessionDir = join(home, '.cursor', 'acp-sessions', cursorSessionId) + if (existsSync(acpSessionDir)) { + return refusal(session.id, 'target_already_exists', `~/.cursor/acp-sessions/${cursorSessionId}/ already exists; refusing to overwrite`, start, this.deps.now) + } + + // Handle force-archive on a live runner. We pre-flighted that + // running/active is allowed only with forceArchiveRunning. Gate + // the archive RPC on session.active === true: a stale + // lifecycleState='running' on an inactive cache row means the + // metadata cleanup write hasn't flushed yet — there is no live + // runner to archive, and archiveSession() would fail with a + // no-registered-handler. The metadata flip itself will clean up + // the stale lifecycle value when it writes cursorSessionProtocol. + // Codex review #34 P2 v6. + const wasActive = session.active === true + if (wasActive) { + if (!this.deps.archiveSession) { + return refusal(session.id, 'internal_error', 'forceArchiveRunning requested but archiveSession dependency not configured', start, this.deps.now) + } + try { + log.info('[migrator] archiving running session before migrate', { sessionId: session.id }) + await this.deps.archiveSession(session.id) + } catch (err) { + return refusal(session.id, 'archive_failed', err instanceof Error ? err.message : String(err), start, this.deps.now) + } + } else if (metadata.lifecycleState === 'running') { + log.info('[migrator] migrating stale lifecycle=running row without archive RPC (no live runner)', { sessionId: session.id }) + } + + // If we just archived an active session, wait for the legacy + // runner's writes to settle. The naive signal — sessionCache.active + // flipping false — is bogus here because archiveSession() calls + // handleSessionEnd() synchronously, so the cache flag is set to + // false BEFORE the runner subprocess has exited and released its + // file descriptors. The hub does not track runner subprocess PIDs + // we could process.kill(pid, 0), so we cannot directly observe + // the runner's exit. + // + // What we DO have: + // (a) SQLite BEGIN IMMEDIATE busy-probe — true while another + // connection holds a write transaction + // (b) size-stability poll on store.db — true once writes settle + // (c) minimum dwell time — fail-safe for the case where the + // runner is idle (no write txn) but still mid-shutdown: + // (a)+(b) can both pass while the subprocess is still in + // SIGTERM cleanup. The dwell guarantees we waited at least + // this long after the archive call. + // + // Codex review #34 P1 v3: removed the previous awaitSessionInactive + // step that polled cache.active — it was self-mutated by the + // archive call so could never block. Replaced with a real minimum + // dwell inside awaitLockRelease itself. + if (wasActive) { + const released = await this.deps.awaitLockRelease(legacy.storeDbPath, this.opts.lockReleaseTimeoutMs) + if (!released) { + return refusal(session.id, 'lock_release_timeout', `legacy store.db file lock not released within ${this.opts.lockReleaseTimeoutMs}ms`, start, this.deps.now) + } + } + + // Read legacy meta for lastUsedModel (best-effort) BEFORE we mutate anything. + const metaInfo = readLegacyMetaLastUsedModel(legacy.storeDbPath) ?? {} + const lastUsedModel = metaInfo.lastUsedModel ?? null + + // Flush the legacy WAL into store.db so a "cp main-file-only" copy + // is complete. Without this, un-checkpointed transactions live in + // store.db-wal and would either be stale-in-target or silently lost + // when the cleanup step removes the WAL sibling. Codex review #34 + // P1: addresses the transplant-WAL-loss case. + try { + this.deps.checkpointLegacyStore(legacy.storeDbPath) + } catch (err) { + return refusal(session.id, 'internal_error', `wal_checkpoint failed before transplant: ${err instanceof Error ? err.message : String(err)}`, start, this.deps.now) + } + + // Codex review #34 P2 v8: TRUNCATE-mode checkpoint zeroes the WAL. + // If we observe WAL bytes BEFORE capturing the baseline fingerprint, + // a writer landed between checkpoint return and this stat — that + // writer's frames are NOT in store.db (we copy main-file-only), + // and accepting them as baseline would let the post-fingerprint + // match pass and the cleanup delete the legacy WAL with those + // frames lost. Refuse rather than baseline-poisoned migrate. + try { + const walSt = statSync(`${legacy.storeDbPath}-wal`) + if (walSt.size > 0) { + return refusal(session.id, 'legacy_store_modified_during_migrate', `store.db-wal grew to ${walSt.size} bytes between checkpoint and fingerprint capture; a writer resumed during the migration window — refusing baseline-poisoned transplant`, start, this.deps.now) + } + } catch (err) { + // WAL absent (most common: TRUNCATE removed it) — fine. + const code = (err as NodeJS.ErrnoException).code + if (code !== 'ENOENT') { + log.warn('[migrator] could not stat store.db-wal post-checkpoint', { sessionId: session.id, err: err instanceof Error ? err.message : String(err) }) + } + } + + // Capture a fingerprint of the legacy store.db AND its WAL/SHM + // sidecars post-checkpoint. Codex review #34 P1 v4: the WAL is + // the place SQLite stages new commits before they merge into the + // main file. A brief resume can write turns to store.db-wal + // while leaving store.db's mtime/size unchanged. We must + // fingerprint the WAL sidecar too — appearance, size change, or + // mtime change all indicate post-checkpoint writes that our cp + // missed. + type FileFp = { exists: true; mtimeMs: number; size: number } | { exists: false } + const fpOf = (p: string): FileFp => { + try { + const st = statSync(p) + return { exists: true, mtimeMs: st.mtimeMs, size: st.size } + } catch { + return { exists: false } + } + } + const fpEqual = (a: FileFp, b: FileFp): boolean => { + if (a.exists !== b.exists) return false + if (!a.exists) return true + // b.exists is also true at this point. + return (b as { exists: true; mtimeMs: number; size: number }).mtimeMs === a.mtimeMs + && (b as { exists: true; mtimeMs: number; size: number }).size === a.size + } + const preFingerprint = { + main: fpOf(legacy.storeDbPath), + wal: fpOf(`${legacy.storeDbPath}-wal`), + shm: fpOf(`${legacy.storeDbPath}-shm`) + } + if (!preFingerprint.main.exists) { + log.warn('[migrator] could not stat legacy store post-checkpoint; skipping pre/post fingerprint check', { sessionId: session.id }) + } + + // Verify-by-temp-home: build a throwaway $HOME, place a copy of the + // legacy store.db there, drive initialize + session/load. The + // session/prompt step (which exercises the agent's response loop) is + // gated on the !skipVerify flag because it requires a live model + // and may legitimately fail on policy-restricted sessions. We ALWAYS + // drive session/load — that's the cheapest reliable proof the + // transplant is loadable. Codex review #34 P2: skipVerify must not + // skip session/load. + let replayNotifications = 0 + const verifyResult = await this.verifyInTempHome(legacy.storeDbPath, cursorSessionId, cwd, { runPrompt: !opts.skipVerify, sourceHome: home }) + if (verifyResult.kind === 'transport_lock_held') { + // Lost the atomic-lock race against another verify probe. + // Codex review #34 P2 v2. + return refusal(session.id, 'acp_transport_active', verifyResult.message, start, this.deps.now) + } + if (verifyResult.kind === 'load_failed') { + return refusal(session.id, 'verify_load_failed', verifyResult.message, start, this.deps.now) + } + if (verifyResult.kind === 'prompt_failed') { + return refusal(session.id, 'verify_prompt_failed', verifyResult.message, start, this.deps.now) + } + replayNotifications = verifyResult.replayNotifications + + // Place the real ACP-sessions entry. cp, not mv - reversible. Atomic + // dir-create (no `recursive: true`) so two concurrent migrate calls + // for the same session cannot both pass the existsSync check and + // mkdir, with one then clobbering the other's store. The parent + // ~/.cursor/acp-sessions exists by precondition (cursor-agent + // creates it on first run; we create it here if absent). + // Codex review #34 P2: atomic target creation. + mkdirSync(join(home, '.cursor', 'acp-sessions'), { recursive: true }) + let acpDirCreated = false + try { + try { + // Codex #34 P2 (round 13): explicit 0o700 mode so the + // session dir is private regardless of umask. On a multi- + // user host with a default 022 umask and a non-private + // ~/.cursor, mkdir without an explicit mode produced 0755 + // — every local user could traverse and read transcripts. + mkdirSync(acpSessionDir, { recursive: false, mode: 0o700 }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (/EEXIST/.test(msg)) { + return refusal(session.id, 'target_already_exists', `~/.cursor/acp-sessions/${cursorSessionId}/ already exists (race with concurrent migrate); refusing to overwrite`, start, this.deps.now) + } + throw err + } + acpDirCreated = true + copyFileSync(legacy.storeDbPath, join(acpSessionDir, 'store.db')) + // Codex #34 P2 (round 13): copyFileSync inherits the source + // file's mode bits, which historically might be 0o644. Force + // 0o600 on the transplanted store so transcript contents are + // owner-only readable. + try { chmodSync(join(acpSessionDir, 'store.db'), 0o600) } catch {} + const sidecarTitle = metaInfo.name && metaInfo.name.trim().length > 0 + ? metaInfo.name.trim() + : undefined + const sidecar: Record = { + schemaVersion: 1, + cwd + } + if (sidecarTitle) sidecar.title = sidecarTitle + writeFileSync(join(acpSessionDir, 'meta.json'), JSON.stringify(sidecar), { mode: 0o600 }) + } catch (err) { + if (acpDirCreated) { + // Only rollback the dir we own (acpDirCreated implies the + // exclusive mkdir succeeded above). + tryRm(acpSessionDir) + } + return refusal(session.id, 'internal_error', `failed to place ACP session: ${err instanceof Error ? err.message : String(err)}`, start, this.deps.now) + } + + // Resume-race recheck. Between preflight and now, the session + // could have been resumed via the existing CLI/web/Telegram paths + // (especially if --force-archive-running was used: archive returns + // immediately, so a quick automation could un-archive). The + // resume path reads cursorSessionProtocol === 'legacy' from + // hapi.db and spawns a stream-json runner against the legacy + // store.db — which we are about to remove. Refuse the flip in + // that case so the running session is not amputated mid-write. + // Codex review #34 P1. + // + // Codex review #34 P2 v5: when WE are the one who just archived + // the session (wasActive=true), the cleanup metadata write that + // flips lifecycleState 'running' → 'archived' may still be in + // flight — handleSessionEnd() runs after killThisHappy() returns. + // In that window the cache shows active=false (set by archive + // synchronously) but lifecycleState is still 'running'. That is + // OUR archive completing, NOT a resume race. Trust the active + // flag in this case; awaitLockRelease's dwell+busy-probe is the + // real safety net against an in-flight runner. + const latest = this.deps.getCurrentSession(session.id, session.namespace) + if (latest && latest.active === true) { + tryRm(acpSessionDir) + return refusal(session.id, 'session_resumed_during_migrate', `session became active during migration (active=true lifecycleState=${latest.lifecycleState ?? 'n/a'}); rolled back ACP placement`, start, this.deps.now) + } + if (latest && !wasActive && latest.lifecycleState === 'running') { + // Lifecycle says running but active=false and we did NOT + // archive this session ourselves. Something external lifted + // it back to running between preflight and now (rare but + // possible if the session was preflight-archived and then + // a peer un-archived it via the lifecycle API). + tryRm(acpSessionDir) + return refusal(session.id, 'session_resumed_during_migrate', `session lifecycle became running during migration (lifecycleState=running, active=false, wasActive=false); rolled back ACP placement`, start, this.deps.now) + } + if (latest && latest.cursorSessionProtocol === 'acp') { + // Someone else migrated this session concurrently. Roll back + // our placement; the other migration's target is canonical. + tryRm(acpSessionDir) + return refusal(session.id, 'already_acp', 'session protocol flipped to acp by a concurrent migration; rolled back', start, this.deps.now) + } + + // Fingerprint check: an archived legacy session could be resumed + // AFTER our checkpoint, write turns to store.db OR store.db-wal, + // then exit before our active recheck above. The active flag + // would already be false but the legacy files would have diverged + // from the copy we just placed at the ACP target. Refuse the + // flip in that case so we don't transplant a stale snapshot. + // Codex review #34 P1 v3+v4 (now covers WAL/SHM sidecars). + if (preFingerprint.main.exists) { + const postFingerprint = { + main: fpOf(legacy.storeDbPath), + wal: fpOf(`${legacy.storeDbPath}-wal`), + shm: fpOf(`${legacy.storeDbPath}-shm`) + } + const changed = ( + !fpEqual(preFingerprint.main, postFingerprint.main) + || !fpEqual(preFingerprint.wal, postFingerprint.wal) + || !fpEqual(preFingerprint.shm, postFingerprint.shm) + ) + if (changed) { + tryRm(acpSessionDir) + const fmt = (label: string, pre: FileFp, post: FileFp) => `${label}: pre=${JSON.stringify(pre)} post=${JSON.stringify(post)}` + return refusal(session.id, 'legacy_store_modified_during_migrate', `legacy store changed during migration window — ${fmt('store.db', preFingerprint.main, postFingerprint.main)}, ${fmt('store.db-wal', preFingerprint.wal, postFingerprint.wal)}, ${fmt('store.db-shm', preFingerprint.shm, postFingerprint.shm)}; rolled back ACP placement`, start, this.deps.now) + } + } + + // Flip metadata. We rely on a caller-provided updater because the + // migrator must not import the hub Store directly (keeps the module + // pure for unit testing). + if (!this.deps.updateSessionAfterMigrate) { + tryRm(acpSessionDir) + return refusal(session.id, 'internal_error', 'updateSessionAfterMigrate dependency not configured', start, this.deps.now) + } + const updateResult = this.deps.updateSessionAfterMigrate(session.id, session.namespace, lastUsedModel) + if (!updateResult.ok) { + tryRm(acpSessionDir) + // Distinguish the atomic active-check failure from the + // metadata-version mismatch case so operators know which + // recovery path applies. Codex review #34 P1 v2. + if (updateResult.reason === 'session_active') { + return refusal(session.id, 'session_resumed_during_migrate', 'session became active inside the metadata flip (atomic check); rolled back ACP placement', start, this.deps.now) + } + return refusal(session.id, 'metadata_write_failed', `hapi.db write failed: ${updateResult.reason}`, start, this.deps.now) + } + + // Remove source unless --keep-source. The rm is the LAST step; if it + // fails, the migration is still considered successful because the ACP + // target is intact and metadata is flipped. + let sourceRemoved = false + if (!opts.keepSource) { + try { + rmSync(legacy.storeDbPath, { force: true }) + // Also drop SQLite sidecars if present (WAL + SHM). + tryRm(`${legacy.storeDbPath}-wal`) + tryRm(`${legacy.storeDbPath}-shm`) + // ONLY rmdir the parent if empty. We never recursively delete + // unknown files - a future cursor-agent version that drops + // additional artifacts in the chat dir would otherwise see + // them silently destroyed. + try { rmdirSync(dirname(legacy.storeDbPath)) } catch {} + sourceRemoved = true + log.info('[migrator] removed legacy source', { sessionId: session.id, path: legacy.storeDbPath }) + } catch (err) { + log.warn('[migrator] legacy source rm failed (target intact, treating as success)', { sessionId: session.id, error: err instanceof Error ? err.message : String(err) }) + } + } + + return { + ok: true, + sessionId: session.id, + acpSessionId: cursorSessionId, + replayNotifications, + durationMs: this.deps.now() - start, + lastUsedModelPreserved: lastUsedModel, + sourceRemoved + } + } + + /** + * Spawn `agent acp` against a temp $HOME, copy auth files, place the + * legacy store.db at /.cursor/acp-sessions//store.db + meta.json, + * drive initialize + session/load + (default) one tiny session/prompt. + * Returns a structured outcome; ALWAYS cleans up the temp dir. + */ + private async verifyInTempHome( + legacyStoreDbPath: string, + cursorSessionId: string, + cwd: string, + opts: { runPrompt: boolean; sourceHome: string } + ): Promise<{ kind: 'ok'; replayNotifications: number } | { kind: 'load_failed'; message: string } | { kind: 'prompt_failed'; message: string } | { kind: 'transport_lock_held'; message: string }> { + const tmpRoot = mkdtempSync(join(this.deps.tmpDir(), 'hapi-acp-verify-')) + const fakeHome = tmpRoot + const fakeAcpSessionDir = join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId) + try { + mkdirSync(fakeAcpSessionDir, { recursive: true }) + copyFileSync(legacyStoreDbPath, join(fakeAcpSessionDir, 'store.db')) + writeFileSync(join(fakeAcpSessionDir, 'meta.json'), JSON.stringify({ schemaVersion: 1, cwd })) + + // Copy auth files from the operator's real ~/.cursor into the temp $HOME. + // Auth tokens are read by `agent acp` at startup; missing files just + // mean no auth, which is fine for session/load (no-network) but breaks + // session/prompt. We try our best; the prompt step degrades gracefully + // if not authed. + const realCursor = join(opts.sourceHome, '.cursor') + const fakeCursor = join(fakeHome, '.cursor') + for (const f of AUTH_FILES) { + const src = join(realCursor, f) + if (existsSync(src)) { + try { copyFileSync(src, join(fakeCursor, f)) } catch {} + } + } + + // Isolate the verify spawn from the operator's real ~/.cursor + // tree AND from the host's HAPI_HOME lock space. On POSIX, HOME + // is the only relevant variable for the ~/.cursor lookup. On + // Windows, `agent` may resolve the user profile from + // USERPROFILE, HOMEDRIVE+HOMEPATH instead — so override all + // three to point at the fake home. Codex review #34 P2: + // without HOME override the verify could touch the real .cursor + // tree. + // + // HAPI_HOME override (added for the auto-migration flow): the + // verify probe's child agent-cli registers an `agent-acp-active` + // lock at `/locks/agent-acp-active/`. The host's real + // HAPI_HOME is shared with every other live ACP transport on the + // machine (peer agents, IDE chats), so the verify probe would + // collide with them. Pointing the child's HAPI_HOME at the same + // per-verify temp dir as HOME gives the probe its own private + // lock space, completely isolated from anything else running. + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: fakeHome, + HAPI_HOME: fakeHome, + NO_COLOR: '1' + } + if (process.platform === 'win32') { + env.USERPROFILE = fakeHome + // Best-effort HOMEDRIVE / HOMEPATH split. If fakeHome lacks + // a drive letter (unusual on win32 but possible in tests), + // leave HOMEDRIVE unset and put the whole path in HOMEPATH. + const driveMatch = /^[A-Za-z]:/.exec(fakeHome) + if (driveMatch) { + env.HOMEDRIVE = driveMatch[0] + env.HOMEPATH = fakeHome.slice(2) + } else { + env.HOMEDRIVE = '' + env.HOMEPATH = fakeHome + } + } + const probe = this.deps.createProbe(env, opts.sourceHome) + const verifyStart = this.deps.now() + const verifyDeadline = verifyStart + this.opts.verifyTimeoutMs + try { + try { + probe.start() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (/agent-acp-active lock is held/.test(msg)) { + return { kind: 'transport_lock_held', message: msg } + } + throw err + } + const initResp = await probe.initialize(remainingTime(verifyDeadline, this.deps.now)) + if (!initResp.ok) { + return { kind: 'load_failed', message: `initialize failed: ${initResp.error.message}` } + } + const loadOut = await probe.loadSession( + { sessionId: cursorSessionId, cwd, mcpServers: [] }, + DEFAULT_REPLAY_DRAIN_MS, + remainingTime(verifyDeadline, this.deps.now) + ) + if (!loadOut.response.ok) { + return { kind: 'load_failed', message: `session/load failed: ${loadOut.response.error.message}` } + } + + // Send the verify prompt only when the caller requested it + // (skipVerify=false). The load above is always run because it + // is the cheapest reliable proof the transplant is loadable. + if (opts.runPrompt) { + const promptOut = await probe.prompt( + { sessionId: cursorSessionId, text: this.opts.verifyPromptText }, + Math.max(15_000, remainingTime(verifyDeadline, this.deps.now)) + ) + if (!promptOut.response.ok) { + return { kind: 'prompt_failed', message: `session/prompt failed: ${promptOut.response.error.message}` } + } + } + + return { kind: 'ok', replayNotifications: loadOut.notificationCount } + } finally { + await probe.stop() + } + } finally { + tryRm(tmpRoot) + } + } +} + +function remainingTime(deadline: number, now: () => number): number { + return Math.max(1_000, deadline - now()) +} + +function tryRm(path: string): void { + try { + rmSync(path, { recursive: true, force: true }) + } catch { + // best-effort; caller logs + } +} + +/** + * Open the legacy store and flush the WAL into the main file with + * `PRAGMA wal_checkpoint(TRUNCATE)`. Idempotent: on non-WAL stores the + * checkpoint is a no-op (SQLite returns busy=0,log=-1,checkpointed=-1). + * + * Codex review #34 P1: a busy=1 result means another connection blocked + * the checkpoint; copying only store.db would lose WAL pages. We treat + * busy=1 as an error so the caller surfaces a refusal rather than + * proceeding with a partial copy. Caller is expected to ensure no other + * process has the DB open (pre-flight: lifecycleState not 'running'; + * archive-then-wait-for-lock-release for the force flag). + */ +function defaultCheckpointLegacyStore(storeDbPath: string): void { + const db = new Database(storeDbPath, { readwrite: true }) + try { + const row = db.query('PRAGMA wal_checkpoint(TRUNCATE)').get() as { busy?: number; log?: number; checkpointed?: number } | undefined + if (row?.busy === 1) { + throw new Error('wal_checkpoint reported busy=1 - another connection has the legacy store open; refusing to copy partial WAL') + } + // For TRUNCATE mode, a fully-merged WAL is signaled by log === 0 AND + // checkpointed === 0 (or both -1 on non-WAL stores). If log !== -1 + // (so we were in WAL) and log !== checkpointed, some frames were + // skipped — refuse rather than transplant stale data. + if (typeof row?.log === 'number' && row.log !== -1 && row.log !== row.checkpointed) { + throw new Error(`wal_checkpoint did not fully apply: log=${row.log}, checkpointed=${row.checkpointed}`) + } + } finally { + db.close() + } +} + +const DEFAULT_MIN_DWELL_MS = 2_000 + +async function defaultAwaitLockRelease(storePath: string, timeoutMs: number, minDwellMs: number = DEFAULT_MIN_DWELL_MS): Promise { + // Three-way release check: + // 1. SQLite BEGIN IMMEDIATE returns BUSY -> another writer + // 2. file size still changing -> mid-write + // 3. minimum dwell -> fail-safe for idle-but-not-yet-exited runners + // The dwell is the only thing protecting against "archive ran, runner + // is shutting down, no writes in flight, no txn held" — without it + // the probe + size-stability would both pass instantly and we could + // copy a store.db whose backing FD is about to be flushed by the + // exiting subprocess. + const start = Date.now() + let lastFileSize = -1 + let stableCount = 0 + const effectiveDwell = Math.min(minDwellMs, Math.max(0, timeoutMs - 250)) + while (Date.now() - start < timeoutMs) { + const elapsed = Date.now() - start + const dwellSatisfied = elapsed >= effectiveDwell + if (dwellSatisfied && !sqliteLockHeldByOtherProcess(storePath) && fileSizeStable()) { + return true + } + await sleep(250) + } + return false + + function fileSizeStable(): boolean { + try { + const st = statSync(storePath) + const size = st.size + if (size === lastFileSize) { + stableCount += 1 + if (stableCount >= 2) return true + } else { + stableCount = 0 + lastFileSize = size + } + } catch { + // File gone = no lock to release. + return true + } + return false + } +} + +/** + * Read the agent acp single-instance lock under $HAPI_HOME (or the same + * tmpdir/hapi fallback the CLI guard uses) and report whether a live PID + * holds it. Mirrors `cli/src/agent/backends/acp/agentCliGuard.ts` + * (intentionally duplicated — the hub does not depend on the CLI module). + * Codex review #34 P1. + */ +function defaultIsAgentAcpTransportActive(): { active: boolean; holderPid: number | null } { + const home = process.env.HAPI_HOME?.trim() || join(tmpdir(), 'hapi') + const lockDir = join(home, 'locks', 'agent-acp-active') + const pidPath = join(lockDir, 'pid') + // Codex review #34 P2 v5: the CLI agent guard creates the lock dir + // BEFORE writing the pid file (cli/src/agent/backends/acp/agentCliGuard.ts). + // If we only check the pid file, we report "inactive" during that + // mid-startup window — and bulk migrations with --force-archive-running + // would then archive a legacy session before verifyInTempHome() races + // the same lock and refuses anyway. Treat lock-dir-exists-but-no-pid + // as ACTIVE so we refuse early, before any side effect. + const dirExists = existsSync(lockDir) + if (!dirExists) return { active: false, holderPid: null } + if (!existsSync(pidPath)) { + // Lock dir exists, no pid file yet — caller is mid-startup. + return { active: true, holderPid: null } + } + let pid: number + try { + const raw = readFileSync(pidPath, 'utf8').trim() + pid = Number(raw) + if (!Number.isInteger(pid) || pid <= 0) { + // Malformed pid file — treat as mid-startup, not stale. + return { active: true, holderPid: null } + } + } catch { + // Read error — be conservative and treat as active. + return { active: true, holderPid: null } + } + try { + process.kill(pid, 0) + return { active: true, holderPid: pid } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + // EPERM means the process exists but we can't signal it. + if (code === 'EPERM') return { active: true, holderPid: pid } + // Pid file present but pid is dead — genuinely stale, treat as + // inactive. The probe-side acquireLock will rmSync the stale + // lock dir on its own first attempt. + return { active: false, holderPid: null } + } +} + +/** + * Probe whether SQLite reports a busy/locked store. We open the file + * readwrite, ask for an IMMEDIATE transaction, then roll back. If another + * process holds a write lock (legacy launcher running an open ACP + * connection), SQLite will report SQLITE_BUSY and we return true. + * Codex review #34 P1: real lock check, not just stat-based stability. + */ +function sqliteLockHeldByOtherProcess(storePath: string): boolean { + let db: Database | null = null + try { + db = new Database(storePath, { readwrite: true }) + db.exec('BEGIN IMMEDIATE') + db.exec('ROLLBACK') + return false + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (/SQLITE_BUSY|database is locked/i.test(msg)) return true + // Anything else (corrupted, unreadable) — treat as not-our-busy-lock + // and let the upstream verify step surface the failure cleanly. + return false + } finally { + try { db?.close() } catch {} + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/hub/src/cursor/cursorLegacyMigratorIntegration.test.ts b/hub/src/cursor/cursorLegacyMigratorIntegration.test.ts new file mode 100644 index 00000000..d24a26ad --- /dev/null +++ b/hub/src/cursor/cursorLegacyMigratorIntegration.test.ts @@ -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') + }) +}) diff --git a/hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts b/hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts new file mode 100644 index 00000000..9200dbf3 --- /dev/null +++ b/hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts @@ -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 = { + 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() + } +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index e6fa84d4..95004905 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -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}. ` + diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 08661b02..632f29a3 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -8,7 +8,7 @@ */ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol' -import type { 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 { + 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 { 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 { + 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 ~3–5s 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 diff --git a/hub/src/sync/syncEngineAutoMigrate.test.ts b/hub/src/sync/syncEngineAutoMigrate.test.ts new file mode 100644 index 00000000..38753dfe --- /dev/null +++ b/hub/src/sync/syncEngineAutoMigrate.test.ts @@ -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 { + 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 { + return await (engine as unknown as { + maybeAutoMigrateLegacyCursorSession(s: Session, ns: string): Promise + }).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 | 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 + } + + 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 + 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() + }) + }) +}) diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index 90a4824d..30a24124 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -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 { + 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) { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 195c54e5..3725a3bd 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -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) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index b8a223ac..f5591310 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -147,6 +147,40 @@ export const RenameSessionRequestSchema = z.object({ export type RenameSessionRequest = z.infer +/** 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 + +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), diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index f1087aee..5671cbe6 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -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(), diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b5b89568..8867d1da 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + const path = `/api/sessions/${encodeURIComponent(sessionId)}/migrate-to-acp` + const tryOnce = async (overrideToken: string | null): Promise => { + 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 { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/switch`, { method: 'POST', diff --git a/web/src/components/CursorMigrationBanner.test.tsx b/web/src/components/CursorMigrationBanner.test.tsx new file mode 100644 index 00000000..2a32eabb --- /dev/null +++ b/web/src/components/CursorMigrationBanner.test.tsx @@ -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({ui}) +} + +afterEach(() => { + cleanup() +}) + +function metadata(partial: Partial = {}): 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() + 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() + expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument() + }) + + it('does not render when metadata is null', () => { + renderWithProviders() + expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument() + }) + + it('does not render when metadata is undefined', () => { + renderWithProviders() + 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() + expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument() + }) + + it('uses role=status and aria-live=polite for screen-reader accessibility', () => { + renderWithProviders() + const status = screen.getByRole('status') + expect(status).toHaveAttribute('aria-live', 'polite') + }) +}) diff --git a/web/src/components/CursorMigrationBanner.tsx b/web/src/components/CursorMigrationBanner.tsx new file mode 100644 index 00000000..a5d5cc66 --- /dev/null +++ b/web/src/components/CursorMigrationBanner.tsx @@ -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 ( +
+
+
+
+ ) +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index ce0a4adb..8aa2f6cc 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -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) { }} /> + + {props.session.teamState && ( )} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index fd8ed84a..f9beb8a9 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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.', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 03e61fb4..3ba767e3 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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 diff --git a/web/src/types/api.ts b/web/src/types/api.ts index f1b7c5fc..b9f5d0f7 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -40,6 +40,7 @@ export type { AgentState, AttachmentMetadata, CodexCollaborationMode, + Metadata, PermissionMode, Machine, RunnerState,