mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP (#844)
* feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP Closes #824 When the operator reopens a legacy stream-json Cursor session in HAPI, the hub now transparently transplants its `~/.cursor/chats/<wsh>/<uuid>/store.db` into `~/.cursor/acp-sessions/<uuid>/`, verifies it loads via `agent acp`, flips `metadata.cursorSessionProtocol = 'acp'`, and removes the legacy source - all before `resumeSession` returns. Subsequent opens are pure ACP. The primary justification is safety, not feature parity. #784 (`cursor-agent` fabricates `Questions skipped by the user` responses in legacy stream-json mode) still fires regularly in dogfood despite #801's mitigation: the agent ships destructive side effects against fabricated consent. Migration to ACP closes the protocol-level door because the `AskQuestion` tool does not exist on the ACP side, so there is nothing to fabricate. working. That tradeoff was reasonable at the time. The accumulated #784 evidence makes legacy sessions actively unsafe; this PR makes the upgrade path invisible enough that users stop avoiding it. A pre-PR spike established that legacy and ACP `store.db` files use the identical SQLite schema; only the directory layout differs. The migrator therefore: 1. Sanity-checks the source store and pre-flips state (`session.active`, `lifecycleState`, on-disk presence, target collision) 2. Optionally archives a stale-running row (`forceArchiveRunning: true` is the default for the auto-migrate path because the caller already verified `session.active === false`) 3. Atomically creates `~/.cursor/acp-sessions/<uuid>/` with mode `0o700` 4. Copies `store.db` and chmods to `0o600` (multi-user-host hardening) 5. Writes a minimal `meta.json` sidecar (`schemaVersion`, `cwd`, optional `title`) with mode `0o600` 6. Spawns `agent acp` under HAPI_HOME isolation and verifies the session loads via `session/load`. On long histories the verify also drives a trivial single-turn prompt; on short ones load-only is enough 7. Flips `cursorSessionProtocol = 'acp'` AND clears the `cursorMigrationState` banner flag in a SINGLE metadata write 8. Removes the legacy source store (only after verify succeeded and the protocol flip committed). The legacy `~/.cursor/chats` parent dir is left as-is Every failure leaves the legacy state intact. No `rm` fires without a verify success AND a committed protocol flip. The transplant takes 15-20s on long histories (copy a multi-hundred-MB store, spawn `agent acp`, replay thousands of notifications, tear down the probe). Without a progress indicator the wait reads as "broken" to a fresh reviewer. A minimal banner ships alongside the migrator: - Hub sets `metadata.cursorMigrationState = 'in_progress'` BEFORE the long-running transplant. The session-cache refresh emits the existing `session-updated` SSE event (no new event type), so the web client picks it up in milliseconds. No client-side polling needed. - Hub clears the flag in the SAME metadata write that flips `cursorSessionProtocol` to `'acp'` on success, so the banner disappears in the same render tick the chat re-renders as ACP - no flicker window. - Hub clears the flag explicitly in the auto-migrate helper's `finally` on failure/exception, so the banner never gets stuck if migration falls back to the legacy launcher. - Web renders an accessible (role=status, aria-live=polite) banner with an indeterminate spinner. Deliberately no fake percentage - we do not have phase data and a fake progress bar would lie. This PR is intentionally sequenced AFTER swear01's three ACP mop-up PRs (merged today asad038bbf,8094b500,fa363c2f), all of which are prerequisite for safe concurrent ACP launches. The verify probe spawns `agent acp` directly via `AcpVerifyProbe` under HAPI_HOME isolation (the migrator overrides `HOME` to a temp dir for the verify pass), so it never touches `<real-HAPI_HOME>/locks/agent-acp-active/` at all. Per swear01's #835 design note, the post-flip ACP launcher claims the lock through the standard `registerActiveAcpTransport` entry and behaves like any other concurrent ACP start. The migrator itself never writes `pid` or `count` files directly. The auto-migrate path is gated by `HAPI_CURSOR_LEGACY_AUTO_MIGRATE`. Set to `0`, `false`, `no`, or `off` to suppress it entirely (legacy sessions keep running through the existing stream-json launcher). Default is on. A REST endpoint at `POST /api/sessions/:id/migrate-to-acp` allows explicit migration of a single session outside the sync-on-open path (e.g. for a specific cold archived session a user wants to re-engage). Bulk migration surfaces (CLI subcommand, web button, bulk REST endpoint, candidate-listing API) were deliberately stripped per reviewer feedback; per-session sync-on-open + this escape hatch are the only two paths. - 343 hub unit tests (4 new for the migration banner flag transitions, 53 for the migrator core, 32 for the verify probe, 15 for the auto- migrate helper guard matrix, plus existing suites) - 3 integration tests against a real `agent acp` (skipped by default unless `HAPI_CURSOR_LEGACY_MIGRATOR_INTEGRATION=1`) - 10 web unit tests for the banner component (visibility paths + a11y) - Typecheck clean across cli, web, hub Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): address upstream Codex review findings on #844 (2 majors) Finding 1 (Major) — verify probe `agentLookupHome` ignored `metadata.homeDir` in service-account hub deployments. `migrateOne` resolved the legacy store under `metadata.homeDir` (the recorded session-owner home) but the default createProbe factory still set `agentLookupHome` from `this.deps.homeDir()` (the hub user's home). On a service-account hub, the store lookup succeeded but `agent acp` discovery fell back to the hub user's `~/.local/bin`, so verify silently failed and sync-on-open quietly fell back to legacy. Fix: - Widen `CursorLegacyMigratorDeps.createProbe` signature from `(env) => AcpVerifyProbe` to `(env, agentLookupHome) => AcpVerifyProbe` - Default factory uses the passed `agentLookupHome` - `verifyInTempHome` threads `opts.sourceHome` (already the resolved session-owner home) through as the 2nd arg 2 new regression tests pin the contract: - service-account case (metadata.homeDir != deps.homeDir()): captured agentLookupHome MUST equal metadata.homeDir - legacy session record (no metadata.homeDir): falls back to deps.homeDir() correctly Finding 2 (Major) — `bun.lock` win32-x64 pinned to 0.20.0 while `cli/package.json` required 0.20.1 (rebase artifact from the v0.20.0 → v0.20.1 release commit landing in upstream/main between the original spike and the rebase). Frozen-install Windows users would either get the wrong native binary or have the lock rejected. Fix: regenerated bun.lock so the entry resolves `@twsxtd/hapi-win32-x64@0.20.1`. `bun install --frozen-lockfile` now passes clean. Test budget: - 391 hub unit tests pass (2 new for createProbe agentLookupHome contract, +0 regressions) - Typecheck clean across cli + web + hub - `bun install --frozen-lockfile` clean Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Unit tests for the AcpVerifyProbe lock-acquisition primitives.
|
||||
*
|
||||
* The probe spawns a real `agent acp` in production. These tests only
|
||||
* cover the lock dance (start/stop side effects on the agent-acp-active
|
||||
* lock dir), NOT the RPC behaviour — that's covered by the integration
|
||||
* tests with a real agent binary.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
import { AcpVerifyProbe, tryAcquireAcpActiveLock } from './acpVerifyProbe'
|
||||
|
||||
describe('AcpVerifyProbe — agent-acp-active lock acquisition (Codex #34 P2 v2)', () => {
|
||||
let hapiHome: string
|
||||
beforeEach(() => {
|
||||
hapiHome = mkdtempSync(join(tmpdir(), 'hapi-acp-lock-test-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
try { rmSync(hapiHome, { recursive: true, force: true }) } catch {}
|
||||
})
|
||||
|
||||
function lockDir(home: string): string {
|
||||
return join(home, 'locks', 'agent-acp-active')
|
||||
}
|
||||
|
||||
it('acquires the lock atomically when no holder exists (and releases on stop)', async () => {
|
||||
const probe = new AcpVerifyProbe({
|
||||
agentBinary: '/usr/bin/env', // any executable; we'll stop() before sending RPC
|
||||
hapiHome
|
||||
})
|
||||
// Spawn would normally fail RPC, but the start path itself should
|
||||
// succeed up through agent spawn — what we care about here is the
|
||||
// lock side-effect.
|
||||
probe.start()
|
||||
expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true)
|
||||
await probe.stop()
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(false)
|
||||
})
|
||||
|
||||
it('throws when the lock is held by another live process (atomic refusal)', () => {
|
||||
// Pre-create the lock dir with a pid that IS alive (our own pid).
|
||||
mkdirSync(lockDir(hapiHome), { recursive: true })
|
||||
writeFileSync(join(lockDir(hapiHome), 'pid'), String(process.pid))
|
||||
|
||||
const probe = new AcpVerifyProbe({
|
||||
agentBinary: '/usr/bin/env',
|
||||
hapiHome
|
||||
})
|
||||
expect(() => probe.start()).toThrow(/agent-acp-active lock is held/)
|
||||
// Pre-existing lock dir must NOT be removed by the failed acquire.
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(true)
|
||||
})
|
||||
|
||||
it('clears a stale lock (dead pid file present) and acquires on retry', async () => {
|
||||
// Pre-create with a pid that is virtually certain to be dead.
|
||||
mkdirSync(lockDir(hapiHome), { recursive: true })
|
||||
writeFileSync(join(lockDir(hapiHome), 'pid'), '999999') // typical max_pid; if collides, test is slightly flaky but unlikely on CI
|
||||
|
||||
const probe = new AcpVerifyProbe({
|
||||
agentBinary: '/usr/bin/env',
|
||||
hapiHome
|
||||
})
|
||||
probe.start()
|
||||
// We acquired by clearing the stale dir and re-creating it.
|
||||
expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true)
|
||||
await probe.stop()
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses on a pidless lock dir (mid-startup race, Codex #34 P2 v3)', () => {
|
||||
// Pre-create an EMPTY lock dir without a pid file — this is what
|
||||
// the CLI guard's registerActiveAcpTransport looks like in the
|
||||
// tiny window between mkdir and writeFileSync. Treating it as
|
||||
// "stale because no pid" would clobber the freshly-starting CLI
|
||||
// ACP transport.
|
||||
mkdirSync(lockDir(hapiHome), { recursive: true })
|
||||
|
||||
const probe = new AcpVerifyProbe({
|
||||
agentBinary: '/usr/bin/env',
|
||||
hapiHome
|
||||
})
|
||||
expect(() => probe.start()).toThrow(/agent-acp-active lock is held/)
|
||||
// The pidless lock dir must still be intact.
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(true)
|
||||
})
|
||||
|
||||
it('stop() does not remove a lock dir owned by another holder', async () => {
|
||||
const probe = new AcpVerifyProbe({
|
||||
agentBinary: '/usr/bin/env',
|
||||
hapiHome
|
||||
})
|
||||
probe.start()
|
||||
// Simulate another process clobbering the pid file before our stop().
|
||||
writeFileSync(join(lockDir(hapiHome), 'pid'), String(process.pid + 1))
|
||||
await probe.stop()
|
||||
// Lock dir is preserved because we no longer own the pid file.
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(true)
|
||||
})
|
||||
|
||||
it('skipLockAcquire makes start() bypass internal acquire and stop() bypass release (Codex #34 P2 v7)', async () => {
|
||||
// Caller (migrator) holds the lock externally.
|
||||
const externalHandle = tryAcquireAcpActiveLock(hapiHome)
|
||||
expect(externalHandle).not.toBeNull()
|
||||
if (!externalHandle) return
|
||||
|
||||
const probe = new AcpVerifyProbe({
|
||||
agentBinary: '/usr/bin/env',
|
||||
hapiHome,
|
||||
skipLockAcquire: true
|
||||
})
|
||||
// start() must NOT throw 'lock is held' — it skipped its internal acquire.
|
||||
probe.start()
|
||||
// Lock still held by the external handle.
|
||||
expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true)
|
||||
await probe.stop()
|
||||
// Lock is still held — probe.stop() did NOT release it.
|
||||
expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true)
|
||||
externalHandle.release()
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to $HOME/.local/bin and $HOME/.npm-global/bin in PATH when spawning agent (live dogfood 2026-06-07 regression: hub systemd unit ships minimal PATH AND the migrator overrides HOME to a tmpdir for isolation)', async () => {
|
||||
// The dogfood failure mode: hapi-hub.service ships minimal PATH and
|
||||
// never sees ~/.local/bin/agent. The migrator additionally overrides
|
||||
// HOME for the verify probe (HAPI_HOME isolation) — so any naive
|
||||
// augmentation that derives bin paths from baseEnv.HOME points at a
|
||||
// tmpdir that doesn't contain agent.
|
||||
//
|
||||
// After codex #34 P2 (round 13): the probe accepts an explicit
|
||||
// `agentLookupHome` option (caller threads its recorded session-
|
||||
// owner home). Falls back to process.env.HOME when not provided.
|
||||
// We pin BOTH: explicit option AND fallback. We also pin that the
|
||||
// existing PATH wins over the fallback (precedence preservation —
|
||||
// codex #34 P2 round-13 finding F3).
|
||||
const stubHome = mkdtempSync(join(tmpdir(), 'hapi-probe-stub-home-'))
|
||||
const stubBin = join(stubHome, '.local', 'bin')
|
||||
mkdirSync(stubBin, { recursive: true })
|
||||
writeFileSync(join(stubBin, 'agent'), '#!/bin/sh\nexit 99\n', { mode: 0o755 })
|
||||
|
||||
const fakeOverrideHome = mkdtempSync(join(tmpdir(), 'hapi-probe-override-home-'))
|
||||
// Deliberately NO .local/bin under fakeOverrideHome.
|
||||
|
||||
// Case A: explicit agentLookupHome wins, env.HOME irrelevant.
|
||||
try {
|
||||
const probe = new AcpVerifyProbe({
|
||||
hapiHome,
|
||||
agentLookupHome: stubHome,
|
||||
env: { HOME: fakeOverrideHome, PATH: '/usr/bin:/bin' }
|
||||
})
|
||||
probe.start()
|
||||
const exited = await new Promise<{ code: number | null }>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (probe['proc'] && probe['proc'].exitCode !== null) {
|
||||
clearInterval(interval)
|
||||
resolve({ code: probe['proc'].exitCode })
|
||||
}
|
||||
}, 10)
|
||||
setTimeout(() => { clearInterval(interval); resolve({ code: -1 }) }, 2000)
|
||||
})
|
||||
expect(exited.code).toBe(99)
|
||||
await probe.stop()
|
||||
} finally {
|
||||
// intentionally leave stubHome in place for case B
|
||||
}
|
||||
|
||||
// Case B: no agentLookupHome → falls back to process.env.HOME.
|
||||
const originalHome = process.env.HOME
|
||||
process.env.HOME = stubHome
|
||||
try {
|
||||
const probe = new AcpVerifyProbe({
|
||||
hapiHome,
|
||||
env: { HOME: fakeOverrideHome, PATH: '/usr/bin:/bin' }
|
||||
})
|
||||
probe.start()
|
||||
const exited = await new Promise<{ code: number | null }>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (probe['proc'] && probe['proc'].exitCode !== null) {
|
||||
clearInterval(interval)
|
||||
resolve({ code: probe['proc'].exitCode })
|
||||
}
|
||||
}, 10)
|
||||
setTimeout(() => { clearInterval(interval); resolve({ code: -1 }) }, 2000)
|
||||
})
|
||||
expect(exited.code).toBe(99)
|
||||
await probe.stop()
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME
|
||||
else process.env.HOME = originalHome
|
||||
try { rmSync(stubHome, { recursive: true, force: true }) } catch {}
|
||||
try { rmSync(fakeOverrideHome, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves explicit options.env.PATH precedence over the cursor-bin fallback (Codex #34 P2 round-13 F3)', async () => {
|
||||
// When the caller deliberately supplies options.env.PATH with a
|
||||
// pinned `agent` (e.g. a staging Cursor install or a wrapper),
|
||||
// the cursor-bin fallback must NOT override it. We test by giving
|
||||
// BOTH a winning PATH entry (priorityBin) AND a fallback entry
|
||||
// (fallbackBin) and asserting the priority wins.
|
||||
const priorityHome = mkdtempSync(join(tmpdir(), 'hapi-probe-priority-'))
|
||||
const priorityBin = join(priorityHome, 'bin')
|
||||
mkdirSync(priorityBin, { recursive: true })
|
||||
writeFileSync(join(priorityBin, 'agent'), '#!/bin/sh\nexit 11\n', { mode: 0o755 })
|
||||
|
||||
const fallbackHome = mkdtempSync(join(tmpdir(), 'hapi-probe-fallback-'))
|
||||
const fallbackBin = join(fallbackHome, '.local', 'bin')
|
||||
mkdirSync(fallbackBin, { recursive: true })
|
||||
writeFileSync(join(fallbackBin, 'agent'), '#!/bin/sh\nexit 22\n', { mode: 0o755 })
|
||||
|
||||
try {
|
||||
const probe = new AcpVerifyProbe({
|
||||
hapiHome,
|
||||
agentLookupHome: fallbackHome,
|
||||
env: { PATH: priorityBin } // explicit PATH wins; fallback bins appended
|
||||
})
|
||||
probe.start()
|
||||
const exited = await new Promise<{ code: number | null }>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (probe['proc'] && probe['proc'].exitCode !== null) {
|
||||
clearInterval(interval)
|
||||
resolve({ code: probe['proc'].exitCode })
|
||||
}
|
||||
}, 10)
|
||||
setTimeout(() => { clearInterval(interval); resolve({ code: -1 }) }, 2000)
|
||||
})
|
||||
expect(exited.code).toBe(11) // priority wins, not 22 (fallback)
|
||||
await probe.stop()
|
||||
} finally {
|
||||
try { rmSync(priorityHome, { recursive: true, force: true }) } catch {}
|
||||
try { rmSync(fallbackHome, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
})
|
||||
|
||||
it('joins augmented PATH with path.delimiter (Codex #34 P2 round-13 F1: Windows uses ; not :)', async () => {
|
||||
// Indirect assertion via spawn behaviour: on linux the delimiter is
|
||||
// `:`. We can't actually drive a win32 spawn from this test runner,
|
||||
// but we can confirm the join uses path.delimiter by checking that
|
||||
// the augmented PATH contains a path.delimiter between segments,
|
||||
// not a hardcoded ':'. Reach into the spawn env via a stubbed
|
||||
// agent that prints its PATH.
|
||||
const stubHome = mkdtempSync(join(tmpdir(), 'hapi-probe-delim-'))
|
||||
const stubBin = join(stubHome, '.local', 'bin')
|
||||
mkdirSync(stubBin, { recursive: true })
|
||||
writeFileSync(
|
||||
join(stubBin, 'agent'),
|
||||
'#!/bin/sh\nprintenv PATH > "$0.path"\nexit 33\n',
|
||||
{ mode: 0o755 }
|
||||
)
|
||||
|
||||
try {
|
||||
const probe = new AcpVerifyProbe({
|
||||
hapiHome,
|
||||
agentLookupHome: stubHome,
|
||||
env: { PATH: '/usr/bin' }
|
||||
})
|
||||
probe.start()
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (probe['proc'] && probe['proc'].exitCode !== null) {
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
}
|
||||
}, 10)
|
||||
setTimeout(() => { clearInterval(interval); resolve() }, 2000)
|
||||
})
|
||||
await probe.stop()
|
||||
const pathFile = join(stubBin, 'agent.path')
|
||||
const seenPath = existsSync(pathFile)
|
||||
? require('node:fs').readFileSync(pathFile, 'utf8').trim()
|
||||
: ''
|
||||
// Should be `/usr/bin<delim><stubHome>/.local/bin<delim><stubHome>/.npm-global/bin`
|
||||
// on linux this means `/usr/bin:/tmp/.../.local/bin:/tmp/.../.npm-global/bin`
|
||||
expect(seenPath).toContain('/usr/bin')
|
||||
expect(seenPath).toContain(`${stubHome}/.local/bin`)
|
||||
// Existing PATH first, fallback appended.
|
||||
const usrBinIdx = seenPath.indexOf('/usr/bin')
|
||||
const fallbackIdx = seenPath.indexOf(`${stubHome}/.local/bin`)
|
||||
expect(usrBinIdx).toBeLessThan(fallbackIdx)
|
||||
} finally {
|
||||
try { rmSync(stubHome, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('tryAcquireAcpActiveLock (Codex #34 P2 v7)', () => {
|
||||
let hapiHome: string
|
||||
beforeEach(() => {
|
||||
hapiHome = mkdtempSync(join(tmpdir(), 'hapi-acp-lock-helper-test-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
try { rmSync(hapiHome, { recursive: true, force: true }) } catch {}
|
||||
})
|
||||
|
||||
function lockDir(home: string): string {
|
||||
return join(home, 'locks', 'agent-acp-active')
|
||||
}
|
||||
|
||||
it('returns a handle on a clean home; release() removes the lock dir', () => {
|
||||
const h = tryAcquireAcpActiveLock(hapiHome)
|
||||
expect(h).not.toBeNull()
|
||||
if (!h) return
|
||||
expect(existsSync(join(lockDir(hapiHome), 'pid'))).toBe(true)
|
||||
h.release()
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns null when another live process holds the lock', () => {
|
||||
// Pre-place an active lock (our pid is alive).
|
||||
mkdirSync(lockDir(hapiHome), { recursive: true })
|
||||
writeFileSync(join(lockDir(hapiHome), 'pid'), String(process.pid))
|
||||
const h = tryAcquireAcpActiveLock(hapiHome)
|
||||
expect(h).toBeNull()
|
||||
// Existing lock dir not clobbered.
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null on a pidless lock dir (mid-startup race)', () => {
|
||||
mkdirSync(lockDir(hapiHome), { recursive: true })
|
||||
const h = tryAcquireAcpActiveLock(hapiHome)
|
||||
expect(h).toBeNull()
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(true)
|
||||
})
|
||||
|
||||
it('clears a stale lock (dead pid) and acquires on retry', () => {
|
||||
mkdirSync(lockDir(hapiHome), { recursive: true })
|
||||
writeFileSync(join(lockDir(hapiHome), 'pid'), '999999')
|
||||
const h = tryAcquireAcpActiveLock(hapiHome)
|
||||
expect(h).not.toBeNull()
|
||||
if (!h) return
|
||||
h.release()
|
||||
})
|
||||
|
||||
it('release() is idempotent', () => {
|
||||
const h = tryAcquireAcpActiveLock(hapiHome)
|
||||
expect(h).not.toBeNull()
|
||||
if (!h) return
|
||||
h.release()
|
||||
h.release() // second call should not throw
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(false)
|
||||
})
|
||||
|
||||
it('release() removes the lock dir even when the pid file is missing (Codex #34 P2 v7)', () => {
|
||||
// Simulate the rare case where mkdir succeeded but writeFileSync(pid)
|
||||
// failed. The handle still owns the dir; release must clean it up.
|
||||
const h = tryAcquireAcpActiveLock(hapiHome)
|
||||
expect(h).not.toBeNull()
|
||||
if (!h) return
|
||||
// Remove the pid file underneath us.
|
||||
rmSync(join(lockDir(hapiHome), 'pid'))
|
||||
h.release()
|
||||
// Lock dir gone — we own it, we remove it even when pidless.
|
||||
expect(existsSync(lockDir(hapiHome))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Minimal JSON-RPC stdio client for `agent acp`.
|
||||
*
|
||||
* Hub-side, internal-only: used by the legacy → ACP migrator's verify step to
|
||||
* confirm that a transplanted store.db can actually be opened by `agent acp`
|
||||
* before we flip metadata and remove the legacy source.
|
||||
*
|
||||
* This is intentionally NOT a full ACP client (those live in cli/src/agent/...).
|
||||
* It speaks only the three calls verify needs: initialize, session/load, and
|
||||
* (optionally) session/prompt. It is decoupled from the launcher loop so it
|
||||
* can spawn against a temp $HOME without engaging any of HAPI's per-session
|
||||
* machinery.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, delimiter as pathDelimiter } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
export interface AcpProbeOptions {
|
||||
/** Path to the `agent` binary. Default 'agent'. */
|
||||
agentBinary?: string
|
||||
/** Override env (used to set HOME for isolation). */
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Default per-request timeout, ms. */
|
||||
timeoutMs?: number
|
||||
/**
|
||||
* Override the $HAPI_HOME directory used for the agent-acp-active lock.
|
||||
* Defaults to `process.env.HAPI_HOME` (with tmpdir/hapi fallback) — same
|
||||
* scheme as cli/src/agent/backends/acp/agentCliGuard.ts. Tests can pass a
|
||||
* temp dir here to avoid clobbering the operator's real lock.
|
||||
*/
|
||||
hapiHome?: string
|
||||
/**
|
||||
* When true, the probe will NOT acquire the agent-acp-active lock in
|
||||
* start() and will NOT release it in stop(). Caller is responsible
|
||||
* for owning the lock for the probe's lifetime. Codex review #34
|
||||
* P2 v7: the migrator pre-acquires the lock BEFORE archiving so a
|
||||
* concurrent ACP spawn cannot land in the window between preflight
|
||||
* and verifyInTempHome.
|
||||
*/
|
||||
skipLockAcquire?: boolean
|
||||
/**
|
||||
* Recorded operator home dir to use for resolving the `agent` binary
|
||||
* on PATH. Used when constructing the fallback PATH augmentation
|
||||
* (~/.local/bin and ~/.npm-global/bin). Defaults to `process.env.HOME`.
|
||||
*
|
||||
* Codex review #34 P2: in deployment shapes where the hub runs as a
|
||||
* service account whose `process.env.HOME` differs from the human
|
||||
* user who installed Cursor (`metadata.homeDir`), the caller (the
|
||||
* migrator) needs to thread its recorded session-owner home through
|
||||
* to the verify probe so the binary lookup happens against the right
|
||||
* filesystem location. Independent of any HOME override passed via
|
||||
* `options.env` (which is for the spawned agent's cache/state
|
||||
* isolation, not its binary lookup).
|
||||
*/
|
||||
agentLookupHome?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle to a held agent-acp-active lock. Created by
|
||||
* tryAcquireAcpActiveLock(); the caller MUST call release() in a
|
||||
* finally block.
|
||||
*/
|
||||
export interface AcpActiveLockHandle {
|
||||
/** Absolute path to the lock directory we own. */
|
||||
lockDir: string
|
||||
/** Release the lock. Idempotent. */
|
||||
release(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the global agent-acp-active lock at the well-known path
|
||||
* `<hapiHome>/locks/agent-acp-active/`. Returns a handle whose
|
||||
* release() removes the lock dir; returns null if the lock is held
|
||||
* by another live (or mid-startup) process. Throws on
|
||||
* non-EEXIST mkdir failures (root-owned HAPI_HOME, read-only fs).
|
||||
*
|
||||
* Codex review #34 P2 v7: extracted so the legacy migrator can
|
||||
* reserve the lock BEFORE archive — closing the gap where another
|
||||
* agent acp could start between preflight and verifyInTempHome.
|
||||
*/
|
||||
export function tryAcquireAcpActiveLock(hapiHome: string): AcpActiveLockHandle | null {
|
||||
const lockDir = join(hapiHome, 'locks', 'agent-acp-active')
|
||||
const pidFile = join(lockDir, 'pid')
|
||||
const parentDir = join(lockDir, '..')
|
||||
try {
|
||||
mkdirSync(parentDir, { recursive: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (!/EEXIST/.test(msg)) {
|
||||
throw new Error(`agent-acp-active lock parent could not be created (path=${parentDir}): ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
const tryAcquire = (): boolean => {
|
||||
try {
|
||||
mkdirSync(lockDir, { recursive: false })
|
||||
try {
|
||||
writeFileSync(pidFile, String(process.pid))
|
||||
} catch {
|
||||
// pid write best-effort; release will still rmdir.
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (!/EEXIST/.test(msg)) {
|
||||
throw new Error(`agent-acp-active lock could not be claimed (path=${lockDir}): ${msg}`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (tryAcquire()) {
|
||||
return makeLockHandle(lockDir, pidFile)
|
||||
}
|
||||
// Lock held — decide if stale and retry once.
|
||||
const probe = inspectLockHolder(pidFile)
|
||||
if (probe.kind === 'dead') {
|
||||
try { rmSync(lockDir, { recursive: true, force: true }) } catch {}
|
||||
if (tryAcquire()) {
|
||||
return makeLockHandle(lockDir, pidFile)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type LockHolderProbe =
|
||||
| { kind: 'live'; pid: number }
|
||||
| { kind: 'dead' }
|
||||
| { kind: 'starting' }
|
||||
|
||||
function inspectLockHolder(pidFile: string): LockHolderProbe {
|
||||
if (!existsSync(pidFile)) return { kind: 'starting' }
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(pidFile, 'utf8').trim()
|
||||
} catch {
|
||||
return { kind: 'starting' }
|
||||
}
|
||||
if (raw.length === 0) return { kind: 'starting' }
|
||||
const pid = Number(raw)
|
||||
if (!Number.isInteger(pid) || pid <= 0) return { kind: 'starting' }
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return { kind: 'live', pid }
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code === 'EPERM') return { kind: 'live', pid }
|
||||
return { kind: 'dead' }
|
||||
}
|
||||
}
|
||||
|
||||
function makeLockHandle(lockDir: string, pidFile: string): AcpActiveLockHandle {
|
||||
let released = false
|
||||
return {
|
||||
lockDir,
|
||||
release() {
|
||||
if (released) return
|
||||
released = true
|
||||
try {
|
||||
let shouldRemove = true
|
||||
if (existsSync(pidFile)) {
|
||||
try {
|
||||
const raw = readFileSync(pidFile, 'utf8').trim()
|
||||
if (raw.length > 0 && raw !== String(process.pid)) {
|
||||
const otherPid = Number(raw)
|
||||
if (Number.isInteger(otherPid) && otherPid > 0) {
|
||||
shouldRemove = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// read error — we own the dir, remove it.
|
||||
}
|
||||
}
|
||||
if (shouldRemove) {
|
||||
rmSync(lockDir, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type AcpRpcResponse =
|
||||
| { ok: true; result: Record<string, unknown> }
|
||||
| { ok: false; error: { code: number; message: string; data?: unknown } }
|
||||
|
||||
export type AcpNotification = {
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Subset of session/load response useful to the migrator. */
|
||||
export interface AcpLoadOutcome {
|
||||
response: AcpRpcResponse
|
||||
notificationCount: number
|
||||
notificationKinds: Record<string, number>
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface AcpPromptOutcome {
|
||||
response: AcpRpcResponse
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export class AcpVerifyProbe {
|
||||
private proc: ChildProcessWithoutNullStreams | null = null
|
||||
private nextId = 0
|
||||
private buf = ''
|
||||
private readonly pending = new Map<number, { resolve: (msg: AcpRpcResponse) => void; timer: NodeJS.Timeout }>()
|
||||
private readonly notifications: AcpNotification[] = []
|
||||
private stderr = ''
|
||||
private readonly defaultTimeoutMs: number
|
||||
private readonly stderrLimit = 4096
|
||||
private lockHeld = false
|
||||
private readonly lockDir: string
|
||||
private readonly lockPidFile: string
|
||||
|
||||
constructor(private readonly options: AcpProbeOptions = {}) {
|
||||
this.defaultTimeoutMs = options.timeoutMs ?? 20_000
|
||||
const home = options.hapiHome ?? process.env.HAPI_HOME?.trim() ?? join(tmpdir(), 'hapi')
|
||||
this.lockDir = join(home, 'locks', 'agent-acp-active')
|
||||
this.lockPidFile = join(this.lockDir, 'pid')
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.proc) return
|
||||
// Codex review #34 P2: register the agent-acp-active lock BEFORE
|
||||
// spawn so concurrent migrations / model-list requests see the
|
||||
// probe as a live ACP transport and back off. Without this, two
|
||||
// parallel migrations could each pass the pre-spawn check, both
|
||||
// spawn agent acp, and the second one's spawn would SIGTERM the
|
||||
// first per Cursor's single-instance enforcement (see
|
||||
// cli/src/agent/backends/acp/agentCliGuard.ts top comment).
|
||||
//
|
||||
// Codex review #34 P2 v7: when the caller (typically the legacy
|
||||
// migrator) has already acquired the lock at the start of its
|
||||
// critical section, skip our internal acquire so we don't fail
|
||||
// EEXIST against the caller's own lock. The caller is then
|
||||
// responsible for releasing.
|
||||
if (!this.options.skipLockAcquire) {
|
||||
this.acquireLock()
|
||||
}
|
||||
|
||||
// Codex review #34 P2: match cursorAcpRemoteLauncher's spawn shape
|
||||
// so the `agent.cmd` shim on Windows is reachable. Without
|
||||
// shell:true + windowsHide:true the spawn fails with ENOENT even
|
||||
// though normal Cursor ACP sessions work.
|
||||
const isWin = process.platform === 'win32'
|
||||
// Live dogfood (2026-06-07) on hapi-hub.service surfaced
|
||||
// `Executable not found in $PATH: "agent"` — the hub's systemd unit
|
||||
// ships a minimal PATH (/usr/local/sbin:/usr/local/bin:/usr/sbin:
|
||||
// /usr/bin:/sbin:/bin) and never sees Cursor's standard install
|
||||
// location at ~/.local/bin/agent. hapi-runner.service hand-fixes
|
||||
// this via Environment=PATH=$HOME/.local/bin:... in its unit file;
|
||||
// we replicate that in code so the hub doesn't depend on the
|
||||
// operator hand-tuning their systemd dropin.
|
||||
//
|
||||
// Resolution order for the lookup home:
|
||||
// 1. options.agentLookupHome — caller-supplied (migrator threads
|
||||
// its recorded session-owner homeDir here; covers the service-
|
||||
// account-hub deployment where process.env.HOME and the human
|
||||
// user who installed Cursor differ)
|
||||
// 2. process.env.HOME — hub process's own home (covers the
|
||||
// common single-user deployment)
|
||||
//
|
||||
// Independently of where the LOOKUP home comes from, options.env
|
||||
// may override HOME for the spawned agent's cache/state isolation
|
||||
// (HAPI_HOME-style sandboxing in the migrator's verifyInTempHome).
|
||||
// The two HOMEs are deliberately separate concerns.
|
||||
//
|
||||
// PATH precedence: we APPEND the fallback bin dirs after the
|
||||
// existing PATH, so any explicit options.env.PATH (e.g. a staging
|
||||
// Cursor install or a pinned wrapper) wins. The fallback only
|
||||
// kicks in when the existing PATH doesn't already contain `agent`.
|
||||
//
|
||||
// Platform: use path.delimiter (`;` on win32, `:` elsewhere) so the
|
||||
// augmented PATH is valid for cmd.exe when this spawn path
|
||||
// delegates to the shell for `agent.cmd`.
|
||||
const baseEnv = this.options.env ?? process.env
|
||||
const lookupHome = this.options.agentLookupHome ?? process.env.HOME ?? ''
|
||||
const cursorBins = lookupHome
|
||||
? [join(lookupHome, '.local', 'bin'), join(lookupHome, '.npm-global', 'bin')]
|
||||
: []
|
||||
const existingPath = baseEnv.PATH ?? ''
|
||||
const augmentedPath = [existingPath, ...cursorBins].filter(Boolean).join(pathDelimiter)
|
||||
const spawnEnv = { ...baseEnv, PATH: augmentedPath }
|
||||
const proc = spawn(this.options.agentBinary ?? 'agent', ['acp'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: spawnEnv,
|
||||
shell: isWin,
|
||||
windowsHide: isWin
|
||||
})
|
||||
this.proc = proc
|
||||
|
||||
proc.stdout.on('data', (chunk: Buffer) => this.handleStdout(chunk.toString('utf8')))
|
||||
proc.stderr.on('data', (chunk: Buffer) => {
|
||||
this.stderr += chunk.toString('utf8')
|
||||
if (this.stderr.length > this.stderrLimit) {
|
||||
this.stderr = this.stderr.slice(-this.stderrLimit)
|
||||
}
|
||||
})
|
||||
// If the child dies, fail all pending requests so callers see a
|
||||
// structured rejection instead of a hang.
|
||||
proc.on('error', (err) => this.failPending(err))
|
||||
proc.on('exit', (code, signal) => {
|
||||
if (this.pending.size > 0) {
|
||||
this.failPending(new Error(`agent acp exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const proc = this.proc
|
||||
this.proc = null
|
||||
if (proc) {
|
||||
// Codex review #34 P2 v7: on Windows we spawn through a shell
|
||||
// (shell: true in start()) so proc.kill only signals the shell
|
||||
// wrapper — the `agent` child can survive. Use taskkill /F /T
|
||||
// to kill the process tree. POSIX kill propagates to the
|
||||
// process group via SIGTERM as long as the child didn't fork.
|
||||
if (process.platform === 'win32' && proc.pid !== undefined) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('node:child_process').execSync(`taskkill /F /T /PID ${proc.pid}`, { stdio: 'ignore', windowsHide: true })
|
||||
} catch {
|
||||
// best-effort — fall through to SIGTERM as a backup
|
||||
try { proc.kill('SIGTERM') } catch {}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
proc.kill('SIGTERM')
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
// Codex review #34 P2 v6: wait for the child to actually exit
|
||||
// before releasing the agent-acp-active lock. Sending SIGTERM
|
||||
// does not mean the process is dead — agent acp may take a
|
||||
// few hundred ms to tear down JSON-RPC and detach stdio. If
|
||||
// we release the lock before then, the next session's
|
||||
// verifier (or any other CLI guard caller) can acquire the
|
||||
// free lock and spawn a second `agent acp` while ours is
|
||||
// still live, hitting Cursor's single-instance enforcement
|
||||
// and SIGTERMing one or both.
|
||||
const alreadyDone = proc.exitCode !== null || proc.signalCode !== null
|
||||
if (!alreadyDone) {
|
||||
await new Promise<void>((resolve) => {
|
||||
let resolved = false
|
||||
const done = () => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
resolve()
|
||||
}
|
||||
proc.once('exit', done)
|
||||
proc.once('close', done)
|
||||
// Hard ceiling so a wedged child cannot hang the
|
||||
// migrator's finally{} block forever. After this
|
||||
// ceiling we fall through to releaseLock and accept
|
||||
// the (now extremely rare) overlap window.
|
||||
setTimeout(done, 5000)
|
||||
})
|
||||
}
|
||||
// Drain any remaining pending requests with a kill error so the caller
|
||||
// does not deadlock waiting on a JSON-RPC response that will never arrive.
|
||||
this.failPending(new Error('agent acp killed by probe.stop()'))
|
||||
}
|
||||
// Release the lock LAST (after kill + exit-wait) so concurrent
|
||||
// requests still see us as active during the entire teardown
|
||||
// window. Codex review #34 P2 / P2 v6.
|
||||
//
|
||||
// Codex review #34 P2 v7: when skipLockAcquire was set, the
|
||||
// caller owns the lock for a longer scope than this probe
|
||||
// instance — don't release theirs.
|
||||
if (!this.options.skipLockAcquire) {
|
||||
this.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
private acquireLock(): void {
|
||||
// Ensure the parent dir exists (idempotent), then atomically claim
|
||||
// the lock dir itself via mkdirSync(recursive:false). The atomic
|
||||
// mkdir fails EEXIST if another lock-holder is already in place
|
||||
// — that is the only race-safe primitive here. mkdirSync + write
|
||||
// is NOT atomic and would let two concurrent migrations both
|
||||
// think they own the lock. Codex review #34 P2 v2.
|
||||
const parentDir = join(this.lockDir, '..')
|
||||
try {
|
||||
mkdirSync(parentDir, { recursive: true })
|
||||
} catch (err) {
|
||||
// Codex review #34 P2 v7: previously silently swallowed. If
|
||||
// we can't even create the parent dir (root-owned HAPI_HOME,
|
||||
// read-only fs), we cannot claim a lock. Fail loud so start()
|
||||
// refuses rather than running unguarded.
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (!/EEXIST/.test(msg)) {
|
||||
throw new Error(`agent-acp-active lock parent could not be created (path=${parentDir}): ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
mkdirSync(this.lockDir, { recursive: false })
|
||||
// Won the race — write our pid IMMEDIATELY (no async work
|
||||
// between the mkdir and the write) so other callers see a
|
||||
// pidful lock as soon as possible. The CLI guard's
|
||||
// clearStaleAcpLockIfNeeded ALSO no longer removes
|
||||
// pid-less dirs (Codex review #34 P3 v6), but tightening
|
||||
// the window here belt-and-suspenders the protection.
|
||||
try {
|
||||
writeFileSync(this.lockPidFile, String(process.pid))
|
||||
} catch {
|
||||
// best-effort: pid file is diagnostic, not the
|
||||
// primary lock primitive (the dir is). releaseLock
|
||||
// will still rmdir on our pid-less lock because
|
||||
// lockHeld=true means we own the mkdir. Codex
|
||||
// review #34 P2 v7.
|
||||
}
|
||||
this.lockHeld = true
|
||||
return
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (!/EEXIST/.test(msg)) {
|
||||
// Codex review #34 P2 v7: a non-EEXIST mkdir failure
|
||||
// (permission denied, read-only fs, disk full) means
|
||||
// we cannot acquire the lock. Previously we silently
|
||||
// returned with lockHeld=false and start() would
|
||||
// spawn agent acp UNGUARDED. Throw instead so
|
||||
// verifyInTempHome surfaces the refusal cleanly.
|
||||
throw new Error(`agent-acp-active lock could not be claimed (path=${this.lockDir}): ${msg}`)
|
||||
}
|
||||
// Lock dir exists. Decide whether the holder is stale.
|
||||
// Codex review #34 P2 v3: a CLI agent guard that just
|
||||
// mkdir'd the lock but has NOT yet written the pid file
|
||||
// would otherwise look "stale" to us and we would delete
|
||||
// their freshly-created live lock. Treat a missing-pid
|
||||
// lock as ACTIVE (probably mid-startup) on the first
|
||||
// attempt; only consider it stale if a pid file IS
|
||||
// present AND the recorded pid is dead.
|
||||
const probe = this.probeLockHolder()
|
||||
if (attempt === 0 && probe.kind === 'dead') {
|
||||
try { rmSync(this.lockDir, { recursive: true, force: true }) } catch {}
|
||||
continue
|
||||
}
|
||||
// Either live, or mid-startup (no pid yet), or we already
|
||||
// retried once. Refuse.
|
||||
throw new Error(`agent-acp-active lock is held (path=${this.lockDir}, holder=${probe.kind === 'live' ? `pid=${probe.pid}` : probe.kind}); refusing to spawn a second agent acp`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspect the current holder of an existing lock dir. */
|
||||
private probeLockHolder(): { kind: 'live'; pid: number } | { kind: 'dead' } | { kind: 'starting' } {
|
||||
try {
|
||||
if (!existsSync(this.lockPidFile)) {
|
||||
// Lock dir exists but pid file not yet written — caller
|
||||
// is in the middle of registerActiveAcpTransport(). Treat
|
||||
// as live to avoid racing them.
|
||||
return { kind: 'starting' }
|
||||
}
|
||||
const raw = readFileSync(this.lockPidFile, 'utf8').trim()
|
||||
const pid = Number(raw)
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
// Malformed pid file — treat as starting (caller may be
|
||||
// mid-write) rather than dead.
|
||||
return { kind: 'starting' }
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return { kind: 'live', pid }
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code === 'EPERM') return { kind: 'live', pid }
|
||||
return { kind: 'dead' }
|
||||
}
|
||||
} catch {
|
||||
return { kind: 'starting' }
|
||||
}
|
||||
}
|
||||
|
||||
private releaseLock(): void {
|
||||
if (!this.lockHeld) return
|
||||
this.lockHeld = false
|
||||
// We own the mkdir (lockHeld was set true by the EEXIST-free
|
||||
// mkdirSync above). Remove the lock dir in two cases:
|
||||
// (a) pid file is OUR pid (normal happy path), OR
|
||||
// (b) pid file is missing/unparseable — Codex review #34 P2 v7:
|
||||
// we own the dir, our pid-write failed (disk full etc.).
|
||||
// The CLI guard now treats pid-less dirs as "starting"
|
||||
// (active), so leaving this here would wedge the lock
|
||||
// forever. We OWN it; we must clean it up.
|
||||
// Skip removal only when the pid file has a DIFFERENT, valid pid
|
||||
// — that would mean another holder somehow took over (shouldn't
|
||||
// happen but defensive).
|
||||
try {
|
||||
let shouldRemove = true
|
||||
if (existsSync(this.lockPidFile)) {
|
||||
try {
|
||||
const raw = readFileSync(this.lockPidFile, 'utf8').trim()
|
||||
if (raw.length > 0 && raw !== String(process.pid)) {
|
||||
const otherPid = Number(raw)
|
||||
if (Number.isInteger(otherPid) && otherPid > 0) {
|
||||
shouldRemove = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// read error — we own the dir, remove it.
|
||||
}
|
||||
}
|
||||
if (shouldRemove) {
|
||||
rmSync(this.lockDir, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
getStderr(): string {
|
||||
return this.stderr
|
||||
}
|
||||
|
||||
getNotifications(): AcpNotification[] {
|
||||
return [...this.notifications]
|
||||
}
|
||||
|
||||
clearNotifications(): void {
|
||||
this.notifications.length = 0
|
||||
}
|
||||
|
||||
/** Send `initialize` and return the response. */
|
||||
initialize(timeoutMs?: number): Promise<AcpRpcResponse> {
|
||||
return this.send('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: false, writeTextFile: false },
|
||||
terminal: false
|
||||
},
|
||||
clientInfo: { name: 'hapi-cursor-legacy-migrator-verify', version: '1' }
|
||||
}, timeoutMs)
|
||||
}
|
||||
|
||||
/** Send `session/load` and capture replay notifications drained over `replayDrainMs`. */
|
||||
async loadSession(params: { sessionId: string; cwd: string; mcpServers?: unknown[] }, replayDrainMs: number = 3_000, timeoutMs?: number): Promise<AcpLoadOutcome> {
|
||||
const start = Date.now()
|
||||
const before = this.notifications.length
|
||||
const response = await this.send('session/load', {
|
||||
sessionId: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? []
|
||||
}, timeoutMs)
|
||||
if (!response.ok) {
|
||||
return {
|
||||
response,
|
||||
notificationCount: 0,
|
||||
notificationKinds: {},
|
||||
durationMs: Date.now() - start
|
||||
}
|
||||
}
|
||||
if (replayDrainMs > 0) {
|
||||
await sleep(replayDrainMs)
|
||||
}
|
||||
const drained = this.notifications.slice(before)
|
||||
const notificationKinds: Record<string, number> = {}
|
||||
for (const n of drained) {
|
||||
const u = (n.params as Record<string, unknown>)?.update as Record<string, unknown> | undefined
|
||||
const kind = typeof u?.sessionUpdate === 'string' ? u.sessionUpdate : '_other'
|
||||
notificationKinds[kind] = (notificationKinds[kind] ?? 0) + 1
|
||||
}
|
||||
return {
|
||||
response,
|
||||
notificationCount: drained.length,
|
||||
notificationKinds,
|
||||
durationMs: Date.now() - start
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(params: { sessionId: string; text: string }, timeoutMs: number = 60_000): Promise<AcpPromptOutcome> {
|
||||
const start = Date.now()
|
||||
const response = await this.send('session/prompt', {
|
||||
sessionId: params.sessionId,
|
||||
prompt: [{ type: 'text', text: params.text }]
|
||||
}, timeoutMs)
|
||||
return { response, durationMs: Date.now() - start }
|
||||
}
|
||||
|
||||
async setModel(params: { sessionId: string; modelId: string }, timeoutMs?: number): Promise<AcpRpcResponse> {
|
||||
return this.send('session/set_model', { sessionId: params.sessionId, modelId: params.modelId }, timeoutMs)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private send(method: string, params: unknown, timeoutMs?: number): Promise<AcpRpcResponse> {
|
||||
if (!this.proc) {
|
||||
return Promise.resolve({ ok: false as const, error: { code: -32603, message: 'agent acp not started' } })
|
||||
}
|
||||
const id = ++this.nextId
|
||||
const t = timeoutMs ?? this.defaultTimeoutMs
|
||||
const req = { jsonrpc: '2.0', id, method, params }
|
||||
const stdin = this.proc.stdin
|
||||
return new Promise<AcpRpcResponse>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
resolve({ ok: false, error: { code: -32603, message: `timeout ${method} after ${t}ms`, data: { stderr_tail: this.stderr.slice(-512) } } })
|
||||
}, t)
|
||||
this.pending.set(id, { resolve, timer })
|
||||
try {
|
||||
stdin.write(`${JSON.stringify(req)}\n`)
|
||||
} catch (err) {
|
||||
clearTimeout(timer)
|
||||
this.pending.delete(id)
|
||||
resolve({ ok: false, error: { code: -32603, message: `stdin write failed: ${err instanceof Error ? err.message : String(err)}` } })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private handleStdout(chunk: string): void {
|
||||
this.buf += chunk
|
||||
let idx: number
|
||||
while ((idx = this.buf.indexOf('\n')) !== -1) {
|
||||
const line = this.buf.slice(0, idx).trim()
|
||||
this.buf = this.buf.slice(idx + 1)
|
||||
if (!line) continue
|
||||
let msg: Record<string, unknown>
|
||||
try {
|
||||
msg = JSON.parse(line) as Record<string, unknown>
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const id = msg.id
|
||||
if (typeof id === 'number' && this.pending.has(id)) {
|
||||
const entry = this.pending.get(id)!
|
||||
this.pending.delete(id)
|
||||
clearTimeout(entry.timer)
|
||||
if (msg.error && typeof msg.error === 'object') {
|
||||
const err = msg.error as Record<string, unknown>
|
||||
entry.resolve({
|
||||
ok: false,
|
||||
error: {
|
||||
code: typeof err.code === 'number' ? err.code : -32603,
|
||||
message: typeof err.message === 'string' ? err.message : 'agent acp error',
|
||||
data: err.data
|
||||
}
|
||||
})
|
||||
} else if (msg.result && typeof msg.result === 'object') {
|
||||
entry.resolve({ ok: true, result: msg.result as Record<string, unknown> })
|
||||
} else {
|
||||
entry.resolve({ ok: false, error: { code: -32603, message: 'malformed agent acp response' } })
|
||||
}
|
||||
} else if (typeof msg.method === 'string' && msg.params && typeof msg.params === 'object') {
|
||||
this.notifications.push({
|
||||
method: msg.method as string,
|
||||
params: msg.params as Record<string, unknown>
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private failPending(err: Error): void {
|
||||
for (const [id, entry] of this.pending.entries()) {
|
||||
clearTimeout(entry.timer)
|
||||
entry.resolve({ ok: false, error: { code: -32603, message: err.message } })
|
||||
this.pending.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Integration test for the legacy stream-json → ACP migrator.
|
||||
*
|
||||
* Spawns a REAL `agent acp` against an isolated $HOME with a synthetic
|
||||
* legacy store.db. Verifies that:
|
||||
* - initialize succeeds
|
||||
* - session/load succeeds against the transplanted store
|
||||
* - one session/prompt completes
|
||||
*
|
||||
* This is the same verify recipe the production migrator runs in its
|
||||
* temp-HOME staging step. The test exists to detect drift between the
|
||||
* cursor-agent on the developer's machine and HAPI's assumptions about
|
||||
* its on-disk layout (#824).
|
||||
*
|
||||
* Opt-in: set CURSOR_AGENT_INTEGRATION=1 to enable. In CI without auth,
|
||||
* keep this off - the unit tests in cursorLegacyMigrator.test.ts cover
|
||||
* every migrator branch with mocks.
|
||||
*
|
||||
* Developer recipe:
|
||||
* CURSOR_AGENT_INTEGRATION=1 bun test src/cursor/cursorLegacyMigratorIntegration.test.ts
|
||||
*
|
||||
* Fodder-strength: if LEGACY_FODDER_WSH + LEGACY_FODDER_UUID are also set,
|
||||
* the test will copy that real on-disk legacy store into the fake $HOME and
|
||||
* verify it survives the full migrator round-trip. The operator's real
|
||||
* ~/.cursor/chats/ is NOT mutated.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import { mkdtempSync, mkdirSync, rmSync, copyFileSync, existsSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
import type { Metadata } from '@hapi/protocol/schemas'
|
||||
import type { Session } from '@hapi/protocol/types'
|
||||
import { CursorLegacyMigrator } from './cursorLegacyMigrator'
|
||||
import { AcpVerifyProbe, tryAcquireAcpActiveLock } from './acpVerifyProbe'
|
||||
import { buildSyntheticLegacyStore } from './fixtures/buildSyntheticLegacyStore'
|
||||
|
||||
const ENABLED = process.env.CURSOR_AGENT_INTEGRATION === '1'
|
||||
|
||||
function agentBinaryAvailable(): boolean {
|
||||
const which = spawnSync('agent', ['--version'], { stdio: 'pipe' })
|
||||
return which.status === 0
|
||||
}
|
||||
|
||||
function copyAuthFiles(realHome: string, fakeHome: string): void {
|
||||
const realCursor = join(realHome, '.cursor')
|
||||
const fakeCursor = join(fakeHome, '.cursor')
|
||||
mkdirSync(fakeCursor, { recursive: true })
|
||||
for (const f of ['cli-config.json', 'agent-cli-state.json', 'acp-config.json']) {
|
||||
const src = join(realCursor, f)
|
||||
if (existsSync(src)) {
|
||||
try { copyFileSync(src, join(fakeCursor, f)) } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const describeIntegration = ENABLED ? describe : describe.skip
|
||||
|
||||
describeIntegration('CursorLegacyMigrator INTEGRATION (real agent acp)', () => {
|
||||
let fakeHome: string
|
||||
let tmp: string
|
||||
beforeEach(() => {
|
||||
if (!ENABLED) return
|
||||
if (!agentBinaryAvailable()) {
|
||||
throw new Error('agent binary not on PATH; install cursor-agent or unset CURSOR_AGENT_INTEGRATION')
|
||||
}
|
||||
fakeHome = mkdtempSync(join(tmpdir(), 'hapi-migrator-integration-home-'))
|
||||
tmp = mkdtempSync(join(tmpdir(), 'hapi-migrator-integration-tmp-'))
|
||||
copyAuthFiles(homedir(), fakeHome)
|
||||
mkdirSync(join(fakeHome, '.cursor', 'chats'), { recursive: true })
|
||||
mkdirSync(join(fakeHome, '.cursor', 'acp-sessions'), { recursive: true })
|
||||
})
|
||||
afterEach(() => {
|
||||
if (!ENABLED) return
|
||||
try { rmSync(fakeHome, { recursive: true, force: true }) } catch {}
|
||||
try { rmSync(tmp, { recursive: true, force: true }) } catch {}
|
||||
})
|
||||
|
||||
it('migrates a tiny synthetic legacy store through the real agent acp verify path', async () => {
|
||||
const cursorSessionId = '11111111-2222-3333-4444-555555555555'
|
||||
const wsh = 'wsh-int'
|
||||
const sourceDir = join(fakeHome, '.cursor', 'chats', wsh, cursorSessionId)
|
||||
mkdirSync(sourceDir, { recursive: true })
|
||||
const sourceStore = join(sourceDir, 'store.db')
|
||||
buildSyntheticLegacyStore({ path: sourceStore, name: 'integration synthetic', lastUsedModel: 'composer-2.5' })
|
||||
|
||||
const updateCalls: Array<{ sessionId: string; namespace: string; lastUsedModel: string | null }> = []
|
||||
const migrator = new CursorLegacyMigrator(
|
||||
{ verifyTimeoutMs: 120_000, verifyPromptText: 'Reply with exactly: ack' },
|
||||
{
|
||||
homeDir: () => fakeHome,
|
||||
hostName: () => "integration",
|
||||
tmpDir: () => tmp,
|
||||
now: () => Date.now(),
|
||||
createProbe: (env) => new AcpVerifyProbe({ env, timeoutMs: 60_000, hapiHome: tmp, skipLockAcquire: true }),
|
||||
awaitLockRelease: async () => true,
|
||||
isAgentAcpTransportActive: () => ({ active: false, holderPid: null }),
|
||||
acquireAcpActiveLock: () => tryAcquireAcpActiveLock(tmp),
|
||||
archiveSession: async () => {},
|
||||
updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => {
|
||||
updateCalls.push({ sessionId, namespace, lastUsedModel })
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const session: Session = {
|
||||
id: 'integration-sess',
|
||||
tag: 'integration-sess',
|
||||
namespace: 'default',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
seq: 0,
|
||||
metadataVersion: 1,
|
||||
agentStateVersion: 1,
|
||||
metadata: {
|
||||
path: tmpdir(),
|
||||
host: 'integration',
|
||||
flavor: 'cursor',
|
||||
cursorSessionId
|
||||
} as Metadata,
|
||||
active: false,
|
||||
model: null,
|
||||
modelReasoningEffort: null,
|
||||
effort: null,
|
||||
permissionMode: undefined,
|
||||
collaborationMode: null,
|
||||
agentState: null,
|
||||
todos: null,
|
||||
todosUpdatedAt: null,
|
||||
teamState: null,
|
||||
teamStateUpdatedAt: null
|
||||
} as unknown as Session
|
||||
|
||||
const out = await migrator.migrateOne(session, {})
|
||||
expect(out.ok).toBe(true)
|
||||
if (!out.ok) return
|
||||
expect(out.acpSessionId).toBe(cursorSessionId)
|
||||
expect(out.sourceRemoved).toBe(true)
|
||||
expect(existsSync(join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId, 'store.db'))).toBe(true)
|
||||
expect(existsSync(sourceStore)).toBe(false)
|
||||
expect(updateCalls).toHaveLength(1)
|
||||
expect(updateCalls[0].lastUsedModel).toBe('composer-2.5')
|
||||
}, 180_000)
|
||||
|
||||
it('migrates a REAL operator-supplied legacy store (LEGACY_FODDER_WSH + LEGACY_FODDER_UUID)', async () => {
|
||||
const fodderWsh = process.env.LEGACY_FODDER_WSH
|
||||
const fodderUuid = process.env.LEGACY_FODDER_UUID
|
||||
if (!fodderWsh || !fodderUuid) {
|
||||
// Skip silently; fodder is operator-local data we can't ship.
|
||||
return
|
||||
}
|
||||
const realSourceStore = join(homedir(), '.cursor', 'chats', fodderWsh, fodderUuid, 'store.db')
|
||||
if (!existsSync(realSourceStore)) {
|
||||
throw new Error(`LEGACY_FODDER_WSH/UUID set but ${realSourceStore} does not exist`)
|
||||
}
|
||||
// Copy into fake HOME — operator's real store is NEVER touched.
|
||||
const fakeSourceDir = join(fakeHome, '.cursor', 'chats', fodderWsh, fodderUuid)
|
||||
mkdirSync(fakeSourceDir, { recursive: true })
|
||||
copyFileSync(realSourceStore, join(fakeSourceDir, 'store.db'))
|
||||
|
||||
const updateCalls: Array<{ sessionId: string; namespace: string; lastUsedModel: string | null }> = []
|
||||
const migrator = new CursorLegacyMigrator(
|
||||
{ verifyTimeoutMs: 180_000 },
|
||||
{
|
||||
homeDir: () => fakeHome,
|
||||
hostName: () => "integration",
|
||||
tmpDir: () => tmp,
|
||||
now: () => Date.now(),
|
||||
createProbe: (env) => new AcpVerifyProbe({ env, timeoutMs: 120_000, hapiHome: tmp, skipLockAcquire: true }),
|
||||
awaitLockRelease: async () => true,
|
||||
isAgentAcpTransportActive: () => ({ active: false, holderPid: null }),
|
||||
acquireAcpActiveLock: () => tryAcquireAcpActiveLock(tmp),
|
||||
archiveSession: async () => {},
|
||||
updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => {
|
||||
updateCalls.push({ sessionId, namespace, lastUsedModel })
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
)
|
||||
const session: Session = {
|
||||
id: 'fodder-sess',
|
||||
tag: 'fodder-sess',
|
||||
namespace: 'default',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
seq: 0,
|
||||
metadataVersion: 1,
|
||||
agentStateVersion: 1,
|
||||
metadata: {
|
||||
path: tmpdir(),
|
||||
host: 'integration',
|
||||
flavor: 'cursor',
|
||||
cursorSessionId: fodderUuid
|
||||
} as Metadata,
|
||||
active: false,
|
||||
model: null,
|
||||
modelReasoningEffort: null,
|
||||
effort: null,
|
||||
permissionMode: undefined,
|
||||
collaborationMode: null,
|
||||
agentState: null,
|
||||
todos: null,
|
||||
todosUpdatedAt: null,
|
||||
teamState: null,
|
||||
teamStateUpdatedAt: null
|
||||
} as unknown as Session
|
||||
|
||||
const out = await migrator.migrateOne(session, { skipVerify: true })
|
||||
// skipVerify because real fodder may have policies (e.g. ask permission, model unavailability) that fail a fresh prompt. The transplant + flip is the regression-critical path.
|
||||
expect(out.ok).toBe(true)
|
||||
if (!out.ok) return
|
||||
expect(out.acpSessionId).toBe(fodderUuid)
|
||||
expect(out.sourceRemoved).toBe(true)
|
||||
expect(existsSync(join(fakeHome, '.cursor', 'acp-sessions', fodderUuid, 'store.db'))).toBe(true)
|
||||
// Operator's real store ON DISK is unaffected because we operated only against fakeHome.
|
||||
expect(existsSync(realSourceStore)).toBe(true)
|
||||
expect(updateCalls).toHaveLength(1)
|
||||
}, 240_000)
|
||||
|
||||
it('refuses to migrate when target collision exists', async () => {
|
||||
const cursorSessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const wsh = 'wsh-collide'
|
||||
const sourceDir = join(fakeHome, '.cursor', 'chats', wsh, cursorSessionId)
|
||||
mkdirSync(sourceDir, { recursive: true })
|
||||
buildSyntheticLegacyStore({ path: join(sourceDir, 'store.db') })
|
||||
// Pre-existing ACP target.
|
||||
mkdirSync(join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId), { recursive: true })
|
||||
writeFileSync(join(fakeHome, '.cursor', 'acp-sessions', cursorSessionId, 'meta.json'), '{}')
|
||||
|
||||
const migrator = new CursorLegacyMigrator({}, {
|
||||
homeDir: () => fakeHome,
|
||||
hostName: () => "integration",
|
||||
tmpDir: () => tmp,
|
||||
now: () => Date.now(),
|
||||
createProbe: (env) => new AcpVerifyProbe({ env, hapiHome: tmp, skipLockAcquire: true }),
|
||||
awaitLockRelease: async () => true,
|
||||
isAgentAcpTransportActive: () => ({ active: false, holderPid: null }),
|
||||
acquireAcpActiveLock: () => tryAcquireAcpActiveLock(tmp),
|
||||
archiveSession: async () => {},
|
||||
updateSessionAfterMigrate: () => ({ ok: true })
|
||||
})
|
||||
const session: Session = {
|
||||
id: 'integration-collide',
|
||||
tag: 'integration-collide',
|
||||
namespace: 'default',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
seq: 0,
|
||||
metadataVersion: 1,
|
||||
agentStateVersion: 1,
|
||||
metadata: {
|
||||
path: tmpdir(),
|
||||
host: 'integration',
|
||||
flavor: 'cursor',
|
||||
cursorSessionId
|
||||
} as Metadata,
|
||||
active: false,
|
||||
model: null,
|
||||
modelReasoningEffort: null,
|
||||
effort: null,
|
||||
permissionMode: undefined,
|
||||
collaborationMode: null,
|
||||
agentState: null,
|
||||
todos: null,
|
||||
todosUpdatedAt: null,
|
||||
teamState: null,
|
||||
teamStateUpdatedAt: null
|
||||
} as unknown as Session
|
||||
const out = await migrator.migrateOne(session, {})
|
||||
expect(out.ok).toBe(false)
|
||||
if (out.ok) return
|
||||
expect(out.reason).toBe('target_already_exists')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Build a synthetic legacy stream-json store.db for tests.
|
||||
*
|
||||
* The real cursor-agent legacy store has the same schema as the ACP one:
|
||||
*
|
||||
* CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB);
|
||||
* CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);
|
||||
*
|
||||
* The migrator only ever reads the meta record (for lastUsedModel + name).
|
||||
* Tests that drive the migrator against a synthetic store can use this
|
||||
* builder to create a sufficiently realistic file without paying token
|
||||
* cost or depending on a real cursor-agent install.
|
||||
*
|
||||
* NOT a public hub export - used only from hub/src/cursor/*.test.ts.
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
export interface BuildSyntheticStoreOpts {
|
||||
/** Absolute file path to write store.db to. Parent dirs created automatically. */
|
||||
path: string
|
||||
/** Free-form session name shown by the IDE; mirrors meta.name. */
|
||||
name?: string
|
||||
/** lastUsedModel hint (legacy stream-json or ACP wireid; both valid). */
|
||||
lastUsedModel?: string
|
||||
/** agentId; arbitrary string (cursor-agent doesn't validate it). */
|
||||
agentId?: string
|
||||
/** ISO timestamp; defaults to now. */
|
||||
createdAt?: string
|
||||
/**
|
||||
* Whether to store meta value as hex-encoded UTF-8 JSON (older cursor-agent
|
||||
* versions) or as raw JSON text (newer versions). Defaults to hex which is
|
||||
* what the on-disk fodder sessions in the spike were stored as.
|
||||
*/
|
||||
metaEncoding?: 'hex' | 'json'
|
||||
}
|
||||
|
||||
export function buildSyntheticLegacyStore(opts: BuildSyntheticStoreOpts): void {
|
||||
const { path } = opts
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
// Pre-touch the file so bun:sqlite definitely creates a fresh DB instead
|
||||
// of opening anything pre-existing.
|
||||
writeFileSync(path, '')
|
||||
const db = new Database(path, { create: true, readwrite: true })
|
||||
try {
|
||||
db.exec('CREATE TABLE IF NOT EXISTS blobs (id TEXT PRIMARY KEY, data BLOB)')
|
||||
db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)')
|
||||
const metaPayload: Record<string, unknown> = {
|
||||
agentId: opts.agentId ?? 'synthetic-agent',
|
||||
latestRootBlobId: 'synthetic-root',
|
||||
name: opts.name ?? 'synthetic legacy chat',
|
||||
mode: 'agent',
|
||||
createdAt: opts.createdAt ?? new Date().toISOString()
|
||||
}
|
||||
if (opts.lastUsedModel) {
|
||||
metaPayload.lastUsedModel = opts.lastUsedModel
|
||||
}
|
||||
const json = JSON.stringify(metaPayload)
|
||||
const encoded = (opts.metaEncoding ?? 'hex') === 'hex'
|
||||
? Buffer.from(json, 'utf8').toString('hex')
|
||||
: json
|
||||
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('record', encoded)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -34,7 +34,7 @@ const REQUIRED_TABLES = [
|
||||
|
||||
export class Store {
|
||||
private db: Database
|
||||
private readonly dbPath: string
|
||||
private readonly _dbPath: string
|
||||
private closed: boolean = false
|
||||
|
||||
readonly sessions: SessionStore
|
||||
@@ -43,8 +43,17 @@ export class Store {
|
||||
readonly users: UserStore
|
||||
readonly push: PushStore
|
||||
|
||||
/**
|
||||
* Filesystem path of the underlying SQLite database, or ':memory:' for
|
||||
* in-memory stores. Used by the legacy → ACP migrator (#824) to take a
|
||||
* backup before a bulk run; treat as read-only.
|
||||
*/
|
||||
get dbPath(): string {
|
||||
return this._dbPath
|
||||
}
|
||||
|
||||
constructor(dbPath: string) {
|
||||
this.dbPath = dbPath
|
||||
this._dbPath = dbPath
|
||||
if (dbPath !== ':memory:' && !dbPath.startsWith('file::memory:')) {
|
||||
const dir = dirname(dbPath)
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
@@ -464,9 +473,9 @@ export class Store {
|
||||
}
|
||||
|
||||
private buildSchemaMismatchError(currentVersion: number): Error {
|
||||
const location = (this.dbPath === ':memory:' || this.dbPath.startsWith('file::memory:'))
|
||||
const location = (this._dbPath === ':memory:' || this._dbPath.startsWith('file::memory:'))
|
||||
? 'in-memory database'
|
||||
: this.dbPath
|
||||
: this._dbPath
|
||||
return new Error(
|
||||
`SQLite schema version mismatch for ${location}. ` +
|
||||
`Expected ${SCHEMA_VERSION}, found ${currentVersion}. ` +
|
||||
|
||||
+338
-3
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol'
|
||||
import type { SlashCommandsResponse } from '@hapi/protocol/apiTypes'
|
||||
import type { CursorMigrateOutcome, CursorMigrateToAcpRequest, SlashCommandsResponse } from '@hapi/protocol/apiTypes'
|
||||
import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
|
||||
import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages'
|
||||
import type { Server } from 'socket.io'
|
||||
@@ -16,6 +16,8 @@ import type { Store, CancelQueuedMessageResult } from '../store'
|
||||
import type { HapiSessionExportResult } from '@hapi/protocol/sessionExport'
|
||||
import type { RpcRegistry } from '../socket/rpcRegistry'
|
||||
import type { SSEManager } from '../sse/sseManager'
|
||||
import { CursorLegacyMigrator, type CursorLegacyMigratorOptions } from '../cursor/cursorLegacyMigrator'
|
||||
|
||||
import { EventPublisher, type SyncEventListener } from './eventPublisher'
|
||||
import { MachineCache, type Machine } from './machineCache'
|
||||
import { MessageService } from './messageService'
|
||||
@@ -430,6 +432,154 @@ export class SyncEngine {
|
||||
this.handleSessionEnd({ sid: sessionId, time: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the post-migration metadata flip in hapi.db:
|
||||
* - metadata.cursorSessionProtocol = 'acp'
|
||||
* - session.model = lastUsedModel (if provided)
|
||||
*
|
||||
* Returns 'success' on a clean write, 'version-mismatch' if the metadata
|
||||
* version moved underneath us (caller retries) or 'not-found' if the row
|
||||
* is gone.
|
||||
*
|
||||
* Used by CursorLegacyMigrator after the on-disk transplant + verify
|
||||
* succeeds. Kept on the engine (not on the migrator) so that all hapi.db
|
||||
* writes funnel through the existing cache-refresh path.
|
||||
*/
|
||||
flipCursorSessionProtocolToAcp(
|
||||
sessionId: string,
|
||||
namespace: string,
|
||||
lastUsedModel: string | null
|
||||
): { result: 'success' | 'version-mismatch' | 'not-found' | 'session-active' } {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
|
||||
?? this.sessionCache.refreshSession(sessionId)
|
||||
if (!latest?.metadata) {
|
||||
return { result: 'not-found' }
|
||||
}
|
||||
// Combined SSE-event payload contract (UX A++): clear the
|
||||
// `cursorMigrationState='in_progress'` flag in the SAME metadata
|
||||
// write that flips `cursorSessionProtocol` to 'acp'. The web
|
||||
// banner keys off `cursorMigrationState`, so a single SSE
|
||||
// session-updated event swaps both atomically — banner gone,
|
||||
// protocol flipped — preventing a flicker window where the
|
||||
// banner has already disappeared but the chat hasn't re-rendered
|
||||
// to the ACP transport yet.
|
||||
const carriedMigrationState = latest.metadata.cursorMigrationState
|
||||
// Atomic active-check inside the same synchronous flip op so
|
||||
// that a resume cannot land between the migrator's recheck
|
||||
// and the actual DB update. Bun is single-threaded — once
|
||||
// we've read `latest` and the row is inactive, no other JS
|
||||
// can mutate active=true until this method returns. Codex
|
||||
// review #34 P1 v2: the migrator's recheck is best-effort;
|
||||
// this is the authoritative gate.
|
||||
//
|
||||
// Codex review #34 P2 v5: only block on `active === true`,
|
||||
// NOT on lifecycleState === 'running'. After a force-archive
|
||||
// flow archiveSession() synchronously sets active=false but
|
||||
// the cleanup metadata write that flips lifecycleState
|
||||
// 'running' → 'archived' may still be in-flight, and that
|
||||
// is OUR archive completing, not a resume race. The active
|
||||
// flag is the authoritative live-runner signal.
|
||||
if (latest.active === true) {
|
||||
return { result: 'session-active' }
|
||||
}
|
||||
// Codex review #34 P2 v7: ALSO clear a stale lifecycleState
|
||||
// value if it still says 'running'. The migrator now skips
|
||||
// archiveSession() for stale-running rows (active=false but
|
||||
// lifecycle=running with --force-archive-running) because
|
||||
// there's no live runner to archive. Without this fixup,
|
||||
// successfully migrated stale rows would retain lifecycle=
|
||||
// running forever and any downstream code that filters by
|
||||
// lifecycleState (not the cache active flag) would keep
|
||||
// treating archived ACP sessions as live.
|
||||
const oldLifecycle = typeof latest.metadata.lifecycleState === 'string' ? latest.metadata.lifecycleState : undefined
|
||||
const nextMetadata: typeof latest.metadata = {
|
||||
...latest.metadata,
|
||||
cursorSessionProtocol: 'acp' as const,
|
||||
...(oldLifecycle === 'running' ? { lifecycleState: 'archived' as const } : {})
|
||||
}
|
||||
// Drop the migration-in-progress flag in the same write (see
|
||||
// header comment). Safe whether or not it was set.
|
||||
if (carriedMigrationState !== undefined) {
|
||||
delete nextMetadata.cursorMigrationState
|
||||
}
|
||||
const result = this.store.sessions.updateSessionMetadata(
|
||||
sessionId,
|
||||
nextMetadata,
|
||||
latest.metadataVersion,
|
||||
namespace,
|
||||
{ touchUpdatedAt: false }
|
||||
)
|
||||
if (result.result === 'version-mismatch') {
|
||||
this.sessionCache.refreshSession(sessionId)
|
||||
continue
|
||||
}
|
||||
if (result.result !== 'success') {
|
||||
return { result: 'not-found' }
|
||||
}
|
||||
this.sessionCache.refreshSession(sessionId)
|
||||
if (lastUsedModel && lastUsedModel.trim().length > 0) {
|
||||
this.store.sessions.setSessionModel(sessionId, lastUsedModel.trim(), namespace, { touchUpdatedAt: false })
|
||||
this.sessionCache.refreshSession(sessionId)
|
||||
}
|
||||
return { result: 'success' }
|
||||
}
|
||||
return { result: 'version-mismatch' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single legacy cursor session to ACP. Hub-side; runs on the
|
||||
* operator's machine (the hub host); see tiann/hapi#824 design.
|
||||
* Returns a structured outcome (ok or refusal); does not throw.
|
||||
*/
|
||||
async migrateLegacyCursorSession(
|
||||
sessionId: string,
|
||||
namespace: string,
|
||||
request: CursorMigrateToAcpRequest
|
||||
): Promise<CursorMigrateOutcome> {
|
||||
const session = this.sessionCache.getSessionByNamespace(sessionId, namespace)
|
||||
?? this.sessionCache.refreshSession(sessionId)
|
||||
if (!session) {
|
||||
return { ok: false, sessionId, reason: 'internal_error', message: 'session not found in namespace', durationMs: 0 }
|
||||
}
|
||||
const migrator = this.buildMigratorForRequest(request)
|
||||
return migrator.migrateOne(session, {
|
||||
keepSource: request.keepSource,
|
||||
forceArchiveRunning: request.forceArchiveRunning,
|
||||
skipVerify: request.skipVerify
|
||||
})
|
||||
}
|
||||
|
||||
private buildMigratorForRequest(_request: CursorMigrateToAcpRequest): CursorLegacyMigrator {
|
||||
const migratorOpts: CursorLegacyMigratorOptions = {}
|
||||
return new CursorLegacyMigrator(migratorOpts, {
|
||||
archiveSession: async (sessionId) => {
|
||||
await this.archiveSession(sessionId)
|
||||
},
|
||||
// NOTE: no awaitSessionInactive injection — handleSessionEnd()
|
||||
// synchronously sets cache.active=false inside archiveSession,
|
||||
// so any cache-based poll would return immediately and provide
|
||||
// false reassurance. The migrator now relies on
|
||||
// awaitLockRelease's minimum-dwell + SQLite busy-probe +
|
||||
// size-stability combination instead. Codex review #34 P1 v3.
|
||||
getCurrentSession: (sessionId, namespace) => {
|
||||
const s = this.sessionCache.getSessionByNamespace(sessionId, namespace)
|
||||
if (!s) return null
|
||||
return {
|
||||
active: s.active === true,
|
||||
lifecycleState: typeof s.metadata?.lifecycleState === 'string' ? s.metadata.lifecycleState : undefined,
|
||||
cursorSessionProtocol: typeof s.metadata?.cursorSessionProtocol === 'string' ? s.metadata.cursorSessionProtocol : undefined
|
||||
}
|
||||
},
|
||||
updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => {
|
||||
const result = this.flipCursorSessionProtocolToAcp(sessionId, namespace, lastUsedModel)
|
||||
if (result.result === 'success') return { ok: true }
|
||||
if (result.result === 'session-active') return { ok: false, reason: 'session_active' as const }
|
||||
return { ok: false, reason: 'version_mismatch_or_missing' as const }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async switchSession(sessionId: string, to: 'remote' | 'local'): Promise<void> {
|
||||
await this.rpcGateway.switchSession(sessionId, to)
|
||||
}
|
||||
@@ -633,6 +783,183 @@ export class SyncEngine {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* tiann/hapi#824 — sync-on-open auto-migration. Returns the (possibly
|
||||
* refreshed-from-cache) session. If the session is a legacy stream-json
|
||||
* Cursor session AND the env flag is on, attempts a transplant migration
|
||||
* synchronously before the caller spawns the runner.
|
||||
*
|
||||
* The migrator's verify probe runs in an isolated HAPI_HOME (see
|
||||
* verifyInTempHome), so this method is safe to call even when other ACP
|
||||
* transports are alive on the host: per tiann/hapi#832, two `agent acp`
|
||||
* processes coexist on the same host without conflict, and swear01's
|
||||
* tiann/hapi#835 refactors the agent-acp-active lock into a cross-process
|
||||
* refcount that explicitly supports this. We rely on #835 landing before
|
||||
* this PR — see manifest layer ordering and the dependency note in
|
||||
* PR #34's body.
|
||||
*
|
||||
* Failure modes are all soft — the session is returned unchanged and the
|
||||
* caller proceeds with the legacy launcher.
|
||||
*/
|
||||
private async maybeAutoMigrateLegacyCursorSession(session: Session, namespace: string): Promise<Session> {
|
||||
const md = session.metadata
|
||||
const flagRaw = process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE?.trim().toLowerCase() ?? ''
|
||||
console.info('[auto-migrate] considering', {
|
||||
sessionId: session.id,
|
||||
flavor: md?.flavor ?? null,
|
||||
proto: md?.cursorSessionProtocol ?? null,
|
||||
hasCursorId: typeof md?.cursorSessionId === 'string' && md.cursorSessionId.length > 0,
|
||||
envFlag: flagRaw === '' ? '(unset; default on)' : flagRaw
|
||||
})
|
||||
|
||||
if (flagRaw === '0' || flagRaw === 'false' || flagRaw === 'no' || flagRaw === 'off') {
|
||||
console.info('[auto-migrate] skipped: env flag disabled', { sessionId: session.id })
|
||||
return session
|
||||
}
|
||||
if (!md || md.flavor !== 'cursor') {
|
||||
console.info('[auto-migrate] skipped: not a cursor session', { sessionId: session.id, flavor: md?.flavor ?? null })
|
||||
return session
|
||||
}
|
||||
if (md.cursorSessionProtocol === 'acp') {
|
||||
console.info('[auto-migrate] skipped: already ACP', { sessionId: session.id })
|
||||
return session
|
||||
}
|
||||
if (typeof md.cursorSessionId !== 'string' || md.cursorSessionId.length === 0) {
|
||||
console.info('[auto-migrate] skipped: no cursorSessionId', { sessionId: session.id })
|
||||
return session
|
||||
}
|
||||
|
||||
console.info('[auto-migrate] starting transplant', { sessionId: session.id, cursorSessionId: md.cursorSessionId })
|
||||
// UX A++: surface the migration to the user via a banner in the
|
||||
// web UI. We set `cursorMigrationState='in_progress'` on the row
|
||||
// BEFORE the long-running transplant; the sessionCache.refresh()
|
||||
// call emits a `session-updated` SSE event the web client uses to
|
||||
// render the banner. The flag is cleared on success by the same
|
||||
// metadata write that flips cursorSessionProtocol to 'acp' (see
|
||||
// flipCursorSessionProtocolToAcp) so the banner disappears in the
|
||||
// same render tick the chat re-renders as ACP — no flicker. On
|
||||
// failure we clear the flag explicitly in the catch path below.
|
||||
const flagSet = this.setCursorMigrationStateInProgress(session.id, namespace)
|
||||
let bannerCleanupNeeded = flagSet
|
||||
try {
|
||||
const migrator = this.buildMigratorForRequest({})
|
||||
// Codex #34 P2 (round 13): for inactive rows whose metadata
|
||||
// still reads `lifecycleState === 'running'` (e.g. orphaned by
|
||||
// a hub crash where the lifecycle transition didn't land), the
|
||||
// migrator's preflight refuses with `running_refused` unless
|
||||
// `forceArchiveRunning` is true. resumeSession's caller-side
|
||||
// guard already ensured `session.active === false` (see the
|
||||
// early-return above), so we know there's no runner to yank;
|
||||
// the `running` lifecycle is stale metadata, not a live agent.
|
||||
// This is exactly the stale-row case the sync-on-open path is
|
||||
// meant to clean up — refusing here would defeat the whole
|
||||
// point and silently fall back to the legacy launcher forever.
|
||||
const outcome = await migrator.migrateOne(session, { forceArchiveRunning: true })
|
||||
if (outcome.ok) {
|
||||
console.info('[auto-migrate] success', {
|
||||
sessionId: session.id,
|
||||
cursorSessionId: md.cursorSessionId,
|
||||
acpSessionId: outcome.acpSessionId,
|
||||
durationMs: outcome.durationMs,
|
||||
sourceRemoved: outcome.sourceRemoved,
|
||||
replayNotifications: outcome.replayNotifications,
|
||||
lastUsedModelPreserved: outcome.lastUsedModelPreserved
|
||||
})
|
||||
// Successful migration already cleared the flag atomically
|
||||
// in flipCursorSessionProtocolToAcp; skip the cleanup write.
|
||||
bannerCleanupNeeded = false
|
||||
const refreshed = this.sessionCache.getSessionByNamespace(session.id, namespace)
|
||||
if (refreshed) return refreshed
|
||||
return session
|
||||
}
|
||||
// Soft fail — log and let the legacy launcher handle it.
|
||||
console.info('[auto-migrate] legacy cursor session left as stream-json', {
|
||||
sessionId: session.id,
|
||||
reason: outcome.reason,
|
||||
message: outcome.message
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[auto-migrate] unexpected error; falling back to legacy launcher', {
|
||||
sessionId: session.id,
|
||||
err: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
} finally {
|
||||
// Failure or exception path: clear the in-progress banner flag
|
||||
// so the user isn't left with a permanent "Upgrading..." banner
|
||||
// even though we silently fell back to the legacy launcher.
|
||||
if (bannerCleanupNeeded) {
|
||||
this.clearCursorMigrationState(session.id, namespace)
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Set `metadata.cursorMigrationState='in_progress'` on the session row
|
||||
* with a single retry on version-mismatch. Returns true if the flag was
|
||||
* persisted (so the caller knows the finally-cleanup is required), false
|
||||
* if the write failed entirely — in which case the banner never appeared
|
||||
* and there's nothing to clean up. UX A++ helper for the auto-migrate
|
||||
* banner; see maybeAutoMigrateLegacyCursorSession.
|
||||
*/
|
||||
private setCursorMigrationStateInProgress(sessionId: string, namespace: string): boolean {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
|
||||
?? this.sessionCache.refreshSession(sessionId)
|
||||
if (!latest?.metadata) return false
|
||||
if (latest.metadata.cursorMigrationState === 'in_progress') return true
|
||||
const nextMetadata = { ...latest.metadata, cursorMigrationState: 'in_progress' as const }
|
||||
const result = this.store.sessions.updateSessionMetadata(
|
||||
sessionId,
|
||||
nextMetadata,
|
||||
latest.metadataVersion,
|
||||
namespace,
|
||||
{ touchUpdatedAt: false }
|
||||
)
|
||||
if (result.result === 'success') {
|
||||
this.sessionCache.refreshSession(sessionId)
|
||||
return true
|
||||
}
|
||||
if (result.result === 'version-mismatch') {
|
||||
this.sessionCache.refreshSession(sessionId)
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear `metadata.cursorMigrationState` (failure / exception cleanup).
|
||||
* Idempotent; safe to call when the flag was never set. UX A++ helper.
|
||||
*/
|
||||
private clearCursorMigrationState(sessionId: string, namespace: string): void {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
|
||||
?? this.sessionCache.refreshSession(sessionId)
|
||||
if (!latest?.metadata) return
|
||||
if (latest.metadata.cursorMigrationState === undefined) return
|
||||
const nextMetadata: typeof latest.metadata = { ...latest.metadata }
|
||||
delete nextMetadata.cursorMigrationState
|
||||
const result = this.store.sessions.updateSessionMetadata(
|
||||
sessionId,
|
||||
nextMetadata,
|
||||
latest.metadataVersion,
|
||||
namespace,
|
||||
{ touchUpdatedAt: false }
|
||||
)
|
||||
if (result.result === 'success') {
|
||||
this.sessionCache.refreshSession(sessionId)
|
||||
return
|
||||
}
|
||||
if (result.result === 'version-mismatch') {
|
||||
this.sessionCache.refreshSession(sessionId)
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Inactive session with directory path but no agent thread and no prior user turn. */
|
||||
private canFreshSpawnNeverStartedSession(session: Session, sessionId: string, namespace: string): boolean {
|
||||
const metadata = session.metadata
|
||||
@@ -655,11 +982,19 @@ export class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
const session = access.session
|
||||
if (session.active) {
|
||||
const initialSession = access.session
|
||||
if (initialSession.active) {
|
||||
return { type: 'success', sessionId: access.sessionId }
|
||||
}
|
||||
|
||||
// tiann/hapi#824 — invisible, automatic, per-session ACP migration on
|
||||
// first open. If this is a legacy stream-json Cursor session and we
|
||||
// can safely migrate it right now (no other agent acp transport
|
||||
// would block the post-migration ACP launcher), run the transplant
|
||||
// synchronously before resuming. The user sees the regular session
|
||||
// loading state for ~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
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Session } from '@hapi/protocol/types'
|
||||
import { Store } from '../store'
|
||||
import { RpcRegistry } from '../socket/rpcRegistry'
|
||||
import { SyncEngine } from './syncEngine'
|
||||
|
||||
/**
|
||||
* tiann/hapi#824 — sync-on-open auto-migration tests.
|
||||
*
|
||||
* The helper `maybeAutoMigrateLegacyCursorSession` is the per-session gate
|
||||
* that runs the transplant migrator inside `resumeSession` before the runner
|
||||
* spawns. These tests cover the guard-clause matrix (env flag, metadata
|
||||
* shape) and the happy-path metadata refresh.
|
||||
*
|
||||
* Per tiann/hapi#832: two `agent acp` processes coexist on the same host
|
||||
* without conflict, and swear01's tiann/hapi#835 refactors the
|
||||
* agent-acp-active lock into a cross-process refcount that explicitly
|
||||
* supports this. So this helper does NOT pre-check the lock — the
|
||||
* migrator's verify probe runs in an isolated HAPI_HOME (see
|
||||
* verifyInTempHome) and the post-migration runner goes through the
|
||||
* normal refcount-aware lock acquisition path.
|
||||
*
|
||||
* The migrator itself has its own 53-test unit suite (cursorLegacyMigrator
|
||||
* .test.ts) and 3 integration tests against a real `agent acp`. Here we only
|
||||
* verify that the SyncEngine triggers it under the right conditions and
|
||||
* honours the env override.
|
||||
*/
|
||||
describe('SyncEngine.maybeAutoMigrateLegacyCursorSession', () => {
|
||||
let store: Store
|
||||
let engine: SyncEngine
|
||||
let hapiHomeRoot: string
|
||||
let originalEnvFlag: string | undefined
|
||||
let originalHapiHome: string | undefined
|
||||
|
||||
function makeLegacySession(overrides: Partial<Session['metadata']> = {}): Session {
|
||||
return {
|
||||
id: 'session-auto-migrate-test',
|
||||
machineId: 'machine-x',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
active: false,
|
||||
model: null,
|
||||
metadata: {
|
||||
path: '/tmp/proj',
|
||||
host: 'localhost',
|
||||
flavor: 'cursor',
|
||||
cursorSessionId: 'cursor-uuid-123',
|
||||
cursorSessionProtocol: 'stream-json',
|
||||
...overrides
|
||||
}
|
||||
} as unknown as Session
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Store(':memory:')
|
||||
engine = new SyncEngine(store, {} as never, new RpcRegistry(), { broadcast() {} } as never)
|
||||
hapiHomeRoot = mkdtempSync(join(tmpdir(), 'auto-migrate-test-'))
|
||||
originalEnvFlag = process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE
|
||||
originalHapiHome = process.env.HAPI_HOME
|
||||
process.env.HAPI_HOME = hapiHomeRoot
|
||||
delete process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnvFlag === undefined) {
|
||||
delete process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE
|
||||
} else {
|
||||
process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE = originalEnvFlag
|
||||
}
|
||||
if (originalHapiHome === undefined) {
|
||||
delete process.env.HAPI_HOME
|
||||
} else {
|
||||
process.env.HAPI_HOME = originalHapiHome
|
||||
}
|
||||
try { rmSync(hapiHomeRoot, { recursive: true, force: true }) } catch {}
|
||||
})
|
||||
|
||||
async function callHelper(session: Session): Promise<Session> {
|
||||
return await (engine as unknown as {
|
||||
maybeAutoMigrateLegacyCursorSession(s: Session, ns: string): Promise<Session>
|
||||
}).maybeAutoMigrateLegacyCursorSession(session, 'default')
|
||||
}
|
||||
|
||||
function stubMigrator(outcome: { ok: boolean; reason?: string; message?: string }): { calls: Array<{ sessionId: string }> } {
|
||||
const calls: Array<{ sessionId: string }> = []
|
||||
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
|
||||
migrateOne: async (s: Session) => {
|
||||
calls.push({ sessionId: s.id })
|
||||
if (outcome.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
sessionId: s.id,
|
||||
legacyStoreDbPath: '/fake',
|
||||
acpSessionDir: '/fake',
|
||||
keptSource: false,
|
||||
replayNotifications: 0,
|
||||
lastUsedModel: null,
|
||||
durationMs: 1
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
sessionId: s.id,
|
||||
reason: outcome.reason ?? 'internal_error',
|
||||
message: outcome.message ?? 'stub failure',
|
||||
durationMs: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
return { calls }
|
||||
}
|
||||
|
||||
it('skips non-cursor sessions without calling the migrator', async () => {
|
||||
const session = makeLegacySession({ flavor: 'codex' as never })
|
||||
const { calls } = stubMigrator({ ok: true })
|
||||
const out = await callHelper(session)
|
||||
expect(out).toBe(session)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips already-ACP cursor sessions without calling the migrator', async () => {
|
||||
const session = makeLegacySession({ cursorSessionProtocol: 'acp' as never })
|
||||
const { calls } = stubMigrator({ ok: true })
|
||||
const out = await callHelper(session)
|
||||
expect(out).toBe(session)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips cursor sessions with no cursorSessionId', async () => {
|
||||
const session = makeLegacySession({ cursorSessionId: undefined })
|
||||
const { calls } = stubMigrator({ ok: true })
|
||||
const out = await callHelper(session)
|
||||
expect(out).toBe(session)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('respects HAPI_CURSOR_LEGACY_AUTO_MIGRATE=0', async () => {
|
||||
process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE = '0'
|
||||
const session = makeLegacySession()
|
||||
const { calls } = stubMigrator({ ok: true })
|
||||
const out = await callHelper(session)
|
||||
expect(out).toBe(session)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('respects HAPI_CURSOR_LEGACY_AUTO_MIGRATE=false', async () => {
|
||||
process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE = 'false'
|
||||
const session = makeLegacySession()
|
||||
const { calls } = stubMigrator({ ok: true })
|
||||
const out = await callHelper(session)
|
||||
expect(out).toBe(session)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('proceeds with migration regardless of the agent-acp-active lock state (refcount-aware after #835)', async () => {
|
||||
// Per tiann/hapi#832/#835: multiple `agent acp` processes coexist on
|
||||
// the same host. The auto-migrate helper does NOT pre-check the
|
||||
// lock — the migrator's verify probe uses HAPI_HOME isolation and
|
||||
// the post-migration runner uses the refcount-aware lock path.
|
||||
const session = makeLegacySession()
|
||||
const { calls } = stubMigrator({ ok: true })
|
||||
const out = await callHelper(session)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(out).toBe(session)
|
||||
})
|
||||
|
||||
it('falls back to the original session when migration fails (soft fail)', async () => {
|
||||
const session = makeLegacySession()
|
||||
const { calls } = stubMigrator({ ok: false, reason: 'target_already_exists', message: 'collision' })
|
||||
const out = await callHelper(session)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(out).toBe(session)
|
||||
})
|
||||
|
||||
it('swallows unexpected migrator errors and returns the original session', async () => {
|
||||
const session = makeLegacySession()
|
||||
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
|
||||
migrateOne: async () => { throw new Error('boom') }
|
||||
})
|
||||
const out = await callHelper(session)
|
||||
expect(out).toBe(session)
|
||||
})
|
||||
|
||||
/**
|
||||
* UX A++ (Codex #34 round 14): the helper sets
|
||||
* `metadata.cursorMigrationState='in_progress'` BEFORE the long-running
|
||||
* transplant, surfacing the migration to the web UI via the SSE
|
||||
* session-updated event. The flag is cleared on failure (and atomically
|
||||
* with the protocol flip on success). These tests pin the metadata
|
||||
* transitions so the web banner has a stable contract.
|
||||
*/
|
||||
describe('UX A++ migration-in-progress banner flag', () => {
|
||||
// Insert a real cursor legacy session into the store so the helper's
|
||||
// metadata writes have something to update.
|
||||
function insertLegacy(sessionId: string): Session {
|
||||
const cache = (engine as unknown as { sessionCache: import('./sessionCache').SessionCache }).sessionCache
|
||||
const persisted = cache.getOrCreateSession(
|
||||
sessionId,
|
||||
{
|
||||
path: '/tmp/proj',
|
||||
host: 'localhost',
|
||||
flavor: 'cursor',
|
||||
cursorSessionId: 'cursor-uuid-real',
|
||||
cursorSessionProtocol: 'stream-json'
|
||||
},
|
||||
null,
|
||||
'default'
|
||||
)
|
||||
return persisted
|
||||
}
|
||||
function getStoredMetadata(sessionId: string): Record<string, unknown> | undefined {
|
||||
const store = (engine as unknown as { store: Store }).store
|
||||
const row = store.sessions.getSession(sessionId)
|
||||
if (!row) return undefined
|
||||
return row.metadata as unknown as Record<string, unknown>
|
||||
}
|
||||
|
||||
it('sets cursorMigrationState=in_progress before the migrator runs and clears it on failure', async () => {
|
||||
const session = insertLegacy('session-flag-fail')
|
||||
|
||||
let observedFlagAtMigrate: unknown
|
||||
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
|
||||
migrateOne: async (s: Session) => {
|
||||
observedFlagAtMigrate = getStoredMetadata(s.id)?.cursorMigrationState
|
||||
return { ok: false, sessionId: s.id, reason: 'internal_error', message: 'stub fail', durationMs: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
await callHelper(session)
|
||||
expect(observedFlagAtMigrate).toBe('in_progress')
|
||||
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets cursorMigrationState=in_progress before the migrator runs and clears it on unexpected exception', async () => {
|
||||
const session = insertLegacy('session-flag-exception')
|
||||
|
||||
let observedFlagAtMigrate: unknown
|
||||
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
|
||||
migrateOne: async (s: Session) => {
|
||||
observedFlagAtMigrate = getStoredMetadata(s.id)?.cursorMigrationState
|
||||
throw new Error('boom')
|
||||
}
|
||||
})
|
||||
|
||||
await callHelper(session)
|
||||
expect(observedFlagAtMigrate).toBe('in_progress')
|
||||
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBeUndefined()
|
||||
})
|
||||
|
||||
it('on migrator success the helper does NOT clear the flag (the flip writer is responsible)', async () => {
|
||||
// Pins the contract that the helper itself does NOT clear the
|
||||
// flag on the ok branch — flipCursorSessionProtocolToAcp does
|
||||
// (in the same write that flips the protocol). We stub a
|
||||
// migrator that returns ok=true WITHOUT calling the flip path,
|
||||
// so the flag should still be set after the helper returns.
|
||||
const session = insertLegacy('session-flag-success-no-clear')
|
||||
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
|
||||
migrateOne: async (s: Session) => ({
|
||||
ok: true,
|
||||
sessionId: s.id,
|
||||
legacyStoreDbPath: '/fake',
|
||||
acpSessionDir: '/fake',
|
||||
keptSource: false,
|
||||
replayNotifications: 0,
|
||||
lastUsedModel: null,
|
||||
durationMs: 1
|
||||
})
|
||||
})
|
||||
await callHelper(session)
|
||||
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('in_progress')
|
||||
})
|
||||
|
||||
it('flipCursorSessionProtocolToAcp clears cursorMigrationState in the same metadata write that flips protocol', () => {
|
||||
const session = insertLegacy('session-flip-clears-flag')
|
||||
const store = (engine as unknown as { store: Store }).store
|
||||
const cache = (engine as unknown as { sessionCache: import('./sessionCache').SessionCache }).sessionCache
|
||||
|
||||
// Manually plant the flag (simulating the helper having run earlier).
|
||||
const initial = store.sessions.getSession(session.id)!
|
||||
const initialMeta = initial.metadata as unknown as Record<string, unknown>
|
||||
store.sessions.updateSessionMetadata(
|
||||
session.id,
|
||||
{ ...initialMeta, cursorMigrationState: 'in_progress' } as unknown as typeof initial.metadata,
|
||||
initial.metadataVersion,
|
||||
'default',
|
||||
{ touchUpdatedAt: false }
|
||||
)
|
||||
cache.refreshSession(session.id)
|
||||
|
||||
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('in_progress')
|
||||
|
||||
const result = engine.flipCursorSessionProtocolToAcp(session.id, 'default', null)
|
||||
expect(result.result).toBe('success')
|
||||
|
||||
const after = getStoredMetadata(session.id)
|
||||
// Both must be true in a SINGLE atomic metadata write.
|
||||
expect(after?.cursorSessionProtocol).toBe('acp')
|
||||
expect(after?.cursorMigrationState).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import {
|
||||
CreateOrLoadMachineRequestSchema,
|
||||
CreateOrLoadSessionRequestSchema,
|
||||
CursorMigrateToAcpRequestSchema,
|
||||
PROTOCOL_VERSION
|
||||
} from '@hapi/protocol'
|
||||
import { getConfiguration } from '../../configuration'
|
||||
@@ -198,6 +199,44 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<Cl
|
||||
return c.json({ messages })
|
||||
})
|
||||
|
||||
app.post('/sessions/:id/migrate-to-acp', async (c) => {
|
||||
const engine = getSyncEngine()
|
||||
if (!engine) {
|
||||
return c.json({ error: 'Not ready' }, 503)
|
||||
}
|
||||
const sessionId = c.req.param('id')
|
||||
const namespace = c.get('namespace')
|
||||
const resolved = resolveSessionForNamespace(engine, sessionId, namespace)
|
||||
if (!resolved.ok) {
|
||||
return c.json({ error: resolved.error }, resolved.status)
|
||||
}
|
||||
// Codex #34 P2 (round 13): mirror the sessions.ts route hardening —
|
||||
// distinguish "no body" from "malformed JSON". A silent fallback to
|
||||
// {} would run the migration with destructive defaults even when
|
||||
// the operator's intended body was mangled in transit.
|
||||
const rawBody = await c.req.text()
|
||||
let body: unknown = {}
|
||||
if (rawBody.trim().length > 0) {
|
||||
try {
|
||||
body = JSON.parse(rawBody)
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid JSON body' }, 400)
|
||||
}
|
||||
}
|
||||
const parsed = CursorMigrateToAcpRequestSchema.safeParse(body ?? {})
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400)
|
||||
}
|
||||
const outcome = await engine.migrateLegacyCursorSession(resolved.sessionId, namespace, parsed.data)
|
||||
const status = outcome.ok ? 200
|
||||
: outcome.reason === 'already_acp' || outcome.reason === 'not_cursor_session' || outcome.reason === 'no_cursor_session_id' ? 409
|
||||
: outcome.reason === 'running_refused' ? 409
|
||||
: outcome.reason === 'target_already_exists' ? 409
|
||||
: outcome.reason === 'no_legacy_store_on_disk' ? 404
|
||||
: 500
|
||||
return c.json(outcome, status)
|
||||
})
|
||||
|
||||
app.post('/machines', async (c) => {
|
||||
const engine = getSyncEngine()
|
||||
if (!engine) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -147,6 +147,40 @@ export const RenameSessionRequestSchema = z.object({
|
||||
|
||||
export type RenameSessionRequest = z.infer<typeof RenameSessionRequestSchema>
|
||||
|
||||
/** Per-session legacy stream-json → ACP migrator request. See tiann/hapi#824. */
|
||||
export const CursorMigrateToAcpRequestSchema = z.object({
|
||||
/** Skip removing the legacy ~/.cursor/chats source store.db even after verify passes. */
|
||||
keepSource: z.boolean().optional(),
|
||||
/** Allow migrating a session whose lifecycleState === 'running' by archiving it first. */
|
||||
forceArchiveRunning: z.boolean().optional(),
|
||||
/** Skip the verify-by-prompt step (session/load alone is run). */
|
||||
skipVerify: z.boolean().optional()
|
||||
})
|
||||
|
||||
export type CursorMigrateToAcpRequest = z.infer<typeof CursorMigrateToAcpRequestSchema>
|
||||
|
||||
export type CursorMigrateOutcome =
|
||||
| { ok: true; sessionId: string; acpSessionId: string; replayNotifications: number; durationMs: number; lastUsedModelPreserved: string | null; sourceRemoved: boolean }
|
||||
| { ok: false; sessionId: string; reason: CursorMigrateRefusalReason; message: string; durationMs: number }
|
||||
|
||||
export type CursorMigrateRefusalReason =
|
||||
| 'not_cursor_session'
|
||||
| 'already_acp'
|
||||
| 'running_refused'
|
||||
| 'no_cursor_session_id'
|
||||
| 'no_legacy_store_on_disk'
|
||||
| 'target_already_exists'
|
||||
| 'verify_load_failed'
|
||||
| 'verify_prompt_failed'
|
||||
| 'metadata_write_failed'
|
||||
| 'archive_failed'
|
||||
| 'lock_release_timeout'
|
||||
| 'acp_transport_active'
|
||||
| 'session_resumed_during_migrate'
|
||||
| 'legacy_store_modified_during_migrate'
|
||||
| 'cross_host_session'
|
||||
| 'internal_error'
|
||||
|
||||
export const UploadFileRequestSchema = z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
content: z.string().min(1),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
} from '@/types/api'
|
||||
import type {
|
||||
CodexModelsResponse,
|
||||
CursorMigrateOutcome,
|
||||
CursorMigrateToAcpRequest,
|
||||
CursorModelsResponse,
|
||||
DeleteUploadResponse,
|
||||
FileReadResponse,
|
||||
@@ -426,6 +428,54 @@ export class ApiClient {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a legacy stream-json Cursor session to ACP. See tiann/hapi#824.
|
||||
*
|
||||
* Refusals (e.g. running session, missing on-disk store, target collision)
|
||||
* are returned as structured `{ok: false, reason, message}` outcomes
|
||||
* rather than thrown - the UI surfaces the reason to the operator and the
|
||||
* underlying state on disk is unchanged.
|
||||
*
|
||||
* 401s trigger the same onUnauthorized refresh path as the shared
|
||||
* `request()` helper so an expired JWT silently re-auths instead of
|
||||
* hard-failing the migration dialog (Codex review #34 P2).
|
||||
*/
|
||||
async migrateCursorSessionToAcp(sessionId: string, body: CursorMigrateToAcpRequest = {}): Promise<CursorMigrateOutcome> {
|
||||
const path = `/api/sessions/${encodeURIComponent(sessionId)}/migrate-to-acp`
|
||||
const tryOnce = async (overrideToken: string | null): Promise<Response> => {
|
||||
const headers = new Headers({ 'content-type': 'application/json' })
|
||||
const liveToken = this.getToken ? this.getToken() : null
|
||||
const authToken = overrideToken ?? liveToken ?? this.token
|
||||
if (authToken) {
|
||||
headers.set('authorization', `Bearer ${authToken}`)
|
||||
}
|
||||
return fetch(this.buildUrl(path), { method: 'POST', headers, body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
let res = await tryOnce(null)
|
||||
if (res.status === 401 && this.onUnauthorized) {
|
||||
const refreshed = await this.onUnauthorized()
|
||||
if (refreshed) {
|
||||
this.token = refreshed
|
||||
res = await tryOnce(refreshed)
|
||||
}
|
||||
}
|
||||
if (res.status === 401) {
|
||||
throw new Error('Session expired. Please sign in again.')
|
||||
}
|
||||
const text = await res.text()
|
||||
let parsed: CursorMigrateOutcome | null = null
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) as CursorMigrateOutcome : null
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && 'ok' in parsed) {
|
||||
return parsed
|
||||
}
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`)
|
||||
}
|
||||
|
||||
async switchSession(sessionId: string): Promise<void> {
|
||||
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/switch`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, cleanup } from '@testing-library/react'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
import { CursorMigrationBanner, isCursorMigrationInProgress } from './CursorMigrationBanner'
|
||||
import type { Metadata } from '@/types/api'
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
return render(<I18nProvider>{ui}</I18nProvider>)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
function metadata(partial: Partial<Metadata> = {}): Metadata {
|
||||
return {
|
||||
path: '/tmp/x',
|
||||
host: 'localhost',
|
||||
...partial
|
||||
} as Metadata
|
||||
}
|
||||
|
||||
describe('isCursorMigrationInProgress', () => {
|
||||
it('returns true when flag is in_progress', () => {
|
||||
expect(isCursorMigrationInProgress(metadata({ cursorMigrationState: 'in_progress' }))).toBe(true)
|
||||
})
|
||||
it('returns false when flag is undefined', () => {
|
||||
expect(isCursorMigrationInProgress(metadata())).toBe(false)
|
||||
})
|
||||
it('returns false when metadata is null', () => {
|
||||
expect(isCursorMigrationInProgress(null)).toBe(false)
|
||||
})
|
||||
it('returns false when metadata is undefined', () => {
|
||||
expect(isCursorMigrationInProgress(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CursorMigrationBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: vi.fn(() => 'en'),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
key: vi.fn(() => null),
|
||||
length: 0
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the banner when cursorMigrationState is in_progress', () => {
|
||||
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorMigrationState: 'in_progress' })} />)
|
||||
expect(screen.getByTestId('cursor-migration-banner')).toBeInTheDocument()
|
||||
expect(screen.getByText('Upgrading Cursor session')).toBeInTheDocument()
|
||||
expect(screen.getByText(/safer ACP protocol/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when cursorMigrationState is undefined', () => {
|
||||
renderWithProviders(<CursorMigrationBanner metadata={metadata()} />)
|
||||
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when metadata is null', () => {
|
||||
renderWithProviders(<CursorMigrationBanner metadata={null} />)
|
||||
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when metadata is undefined', () => {
|
||||
renderWithProviders(<CursorMigrationBanner metadata={undefined} />)
|
||||
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when migration is complete (cursorSessionProtocol is acp but no in_progress flag)', () => {
|
||||
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorSessionProtocol: 'acp' })} />)
|
||||
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses role=status and aria-live=polite for screen-reader accessibility', () => {
|
||||
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorMigrationState: 'in_progress' })} />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-live', 'polite')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* CursorMigrationBanner
|
||||
*
|
||||
* Surfaces the in-progress automatic legacy-stream-json → ACP migration to
|
||||
* the user, so the 15-20s "dark wait" while the migrator transplants the
|
||||
* store.db, spawns `agent acp`, replays notifications, and tears down the
|
||||
* verify probe doesn't read as "broken / nothing is happening".
|
||||
*
|
||||
* Visibility contract:
|
||||
* - Renders when `session.metadata.cursorMigrationState === 'in_progress'`
|
||||
* - Hub flips the flag → SSE `session-updated` → React Query cache → this
|
||||
* re-renders within milliseconds (no client-side polling needed; the
|
||||
* hub's session-updated channel is already real-time).
|
||||
* - Hub clears the flag in the SAME metadata write that flips
|
||||
* `cursorSessionProtocol` to 'acp' on success, so the banner disappears
|
||||
* in the same render tick the chat re-renders as ACP — no flicker.
|
||||
* - On failure / exception the hub clears the flag explicitly in the
|
||||
* auto-migrate helper's finally, so the banner never gets stuck.
|
||||
*
|
||||
* Deliberately minimal — no fake progress bar (we don't have phase data and
|
||||
* a fake percentage would lie); just an indeterminate spinner + a short
|
||||
* explanation. UX A++ design notes are in PR #34's body.
|
||||
*/
|
||||
|
||||
import type { Metadata } from '@/types/api'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
export function isCursorMigrationInProgress(metadata: Metadata | undefined | null): boolean {
|
||||
if (!metadata) return false
|
||||
return metadata.cursorMigrationState === 'in_progress'
|
||||
}
|
||||
|
||||
export function CursorMigrationBanner({ metadata }: { metadata: Metadata | undefined | null }) {
|
||||
const { t } = useTranslation()
|
||||
if (!isCursorMigrationInProgress(metadata)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="px-3 pt-3" data-testid="cursor-migration-banner">
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="mx-auto flex w-full max-w-content items-start gap-3 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-text)]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 inline-block h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{t('session.cursorMigration.banner.title')}</div>
|
||||
<div className="text-xs text-[var(--app-hint)]">
|
||||
{t('session.cursorMigration.banner.body')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { useHappyRuntime } from '@/lib/assistant-runtime'
|
||||
import { createAttachmentAdapter } from '@/lib/attachmentAdapter'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { SessionHeader } from '@/components/SessionHeader'
|
||||
import { CursorMigrationBanner } from '@/components/CursorMigrationBanner'
|
||||
import { TeamPanel } from '@/components/TeamPanel'
|
||||
import { usePlatform } from '@/hooks/usePlatform'
|
||||
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
|
||||
@@ -982,6 +983,8 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
<CursorMigrationBanner metadata={props.session.metadata} />
|
||||
|
||||
{props.session.teamState && (
|
||||
<TeamPanel teamState={props.session.teamState} />
|
||||
)}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,6 +40,7 @@ export type {
|
||||
AgentState,
|
||||
AttachmentMetadata,
|
||||
CodexCollaborationMode,
|
||||
Metadata,
|
||||
PermissionMode,
|
||||
Machine,
|
||||
RunnerState,
|
||||
|
||||
Reference in New Issue
Block a user