fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression) (#877)

* fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression)

The legacy-to-ACP migrator's `findLegacyChatStore()` walks
`~/.cursor/chats/<workspace-hash>/<cursorSessionId>/store.db` via
`readdirSync()` and returns the FIRST match. When the same cursor
session id exists in more than one workspace-hash drawer (operator
opened the session from a worktree, an old workspace clone, etc.)
the readdir order picks an arbitrary candidate. The migrator then
transplants alien content into the ACP target, deletes the source
drawer, and reports success - because the verify probe only checks
"loads cleanly", not "loaded the right content". Operator session
resurrects with no recall of its real history.

Four-part fix (all four must land together):

1. Path-priority discovery in `findLegacyChatStore(id, home, cwd?)`:
   - Optional 3rd arg = canonical workspace path (caller passes
     `session.metadata.path`).
   - Compute md5(cwd) and check that drawer FIRST.
   - Fall back to readdir scan only if the canonical drawer is empty.
   - If 2+ candidates remain after fallback, throw
     `AmbiguousLegacyStoreError` listing all of them
     (workspaceHash, sizeBytes, mtimeMs).
2. Ambiguity surface in `maybeAutoMigrateLegacyCursorSession`:
   - Catch `ambiguous_legacy_store` / `size_mismatch` refusals and
     promote `cursorMigrationState` from 'in_progress' to a new
     'ambiguous' state instead of silently clearing the banner.
     Operator sees an actionable web-banner.
3. Size sanity check before transplant:
   - Compare HAPI's known message count (new `MessageStore.countMessages`
     + `CursorLegacyMigratorDeps.getHapiMessageCount` dep) against
     the candidate `store.db`'s blob count. If message count > 100
     AND blob count < messageCount/4, refuse with `size_mismatch`.
   - Skipped when message count is 0 (brand-new session) or the dep
     is unwired (unit tests, CLI direct callers).
4. Diagnostic logging on every successful transplant:
   - `[migrator] transplanted` info log capturing cursorSessionId,
     picked workspaceHash, candidate count discovered, sourceBytes,
     sourceBlobCount, targetAcpPath, sourceRemoved, canonical-path
     md5. Future regressions of this bug shape are diagnosable from
     `journalctl -u hapi-hub` without blob-overlap forensics.

Tests added in `hub/src/cursor/cursorLegacyMigrator.test.ts`:
  - regression guard for single-drawer discovery
  - canonical-path wins over readdir order
  - ambiguity throws with all candidates listed (3-drawer + 2-drawer
    no-canonical-arg variants)
  - canonical-path resolves ambiguity cleanly
  - listLegacyChatStoreCandidates enumeration
  - workspaceHashFromPath shape
  - migrateOne happy path with canonical workspace + 3 sibling decoys
  - migrateOne refuses with ambiguous_legacy_store (3 drawers, no
    canonical match) and leaves all sources untouched
  - migrateOne proceeds when canonical path resolves
  - size_mismatch refuses tiny candidate when messageCount=6000
  - size_mismatch passes when candidate blob count meets the floor
  - size sanity skipped on messageCount=0, missing dep, throwing dep,
    boundary (messageCount=100)
  - countLegacyStoreBlobs returns counts / null on bad path
And in `hub/src/sync/syncEngineAutoMigrate.test.ts`:
  - cursorMigrationState promoted to 'ambiguous' on
    ambiguous_legacy_store / size_mismatch refusals.

Schema:
  - `shared/src/schemas.ts`: cursorMigrationState enum gains 'ambiguous'.
  - `shared/src/apiTypes.ts`: CursorMigrateRefusalReason gains
    'ambiguous_legacy_store' + 'size_mismatch'.

Real-world repro (operator's tooling session, 2026-06-09): three legacy
drawers contained one cursor session id - one with the real 21k-blob
history, two with stale 19/568-blob diagnostic snapshots. Migrator
silently transplanted the 568-blob alien content; resurrected session
had no memory of prior history. Manual rescue completed; this fix
prevents recurrence and surfaces the ambiguity to the operator instead.

* fix(cursor): address cold review on migrator path-priority fix

Self-review against the cold-PR rubric surfaces four polish items on
the previous commit; all four addressed in-loop before push.

- Major: `migrator:transplanted` candidate count was captured AFTER
  the source rm, so for the dominant single-candidate happy path the
  log reported `candidateCount=0, sourceRemoved=true`. Useless for
  diagnosing a future regression of the bug shape this PR is fixing.
  Snapshot candidates + source-side size + source-side blob count
  BEFORE any destructive step and use those for the log.
- Minor: `sourceBytes` and `sourceBlobCount` were read from the
  destination path (acpSessionDir/store.db). The cp guarantees they
  match, but the field names imply source-side measurement. Now they
  measure the source directly.
- Minor: `setCursorMigrationStateAmbiguous` silently returned false on
  cache miss / repeated version mismatch / write failure, letting the
  finally{} block clear the banner without any log. Now emits a
  warn-level log so the gap is diagnosable from journalctl.
- Minor: `findLegacyChatStore` is exported public API and used as a
  free function in unit tests. An out-of-band caller bypassing
  preflightSession could pass `..` or `/etc/passwd` and have the inner
  `join(chatsRoot, wsh, id, 'store.db')` resolve to an arbitrary on-
  disk path. The probe is read-only `statSync` so blast radius is
  small, but enforce the same CURSOR_SESSION_ID_RE at the function
  boundary as a defence-in-depth. New unit test locks the behaviour.

Hub test suite: 414 pass, 0 fail. Typecheck clean across cli/web/hub.

* fix(cursor): cold-review polish on migrator path-priority (tiann/hapi#873)

- Web `CursorMigrationBanner` now renders a "Manual review needed"
  state for `cursorMigrationState === 'ambiguous'` (Major #1: caller
  was promoting the metadata flag but no UI surfaced it).
- Pin the md5-fixture contract for `workspaceHashFromPath`: raw,
  no-normalization, trailing-slash-distinct hashes computed via
  `printf '%s' <path> | md5sum` (Major #2: prevents algorithm drift
  that would silently revert path-priority discovery to fallback).
- Snapshot full candidate set BEFORE the canonical fast-path resolves
  a single drawer so the `migrator:transplanted` log reports the
  decision-time count, not a post-rm undercount (Minor #1).
- Warn log when canonical-path drawer is missing but readdir hands
  back exactly one candidate - regression-equivalent behaviour, but
  the size mismatch warrants a journalctl trail (path-normalization
  corner case the maintainer can grep for).
- Boundary test: `messageCount = 101` (first value above the skip
  threshold) engages the size sanity check, pinning the cutoff
  contract (Nit).
- Schema docstring on `cursorMigrationState` enum spelling out the
  banner contract per value (Nit).
- syncEngine `getHapiMessageCount` warn-logs `countMessages` throws
  instead of silently downgrading to 0 (would chronically disable
  the floor).

Drafted with claude-4.6-sonnet-thinking via Cursor; reviewed and
tested by the operator. tiann/hapi#873.

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

* fix(cursor): correct log-search strings in ambiguous banner copy

The en/zh-CN locale strings told users to grep for
'migrator:ambiguous_legacy_store' and 'migrator:size_mismatch'
but the hub emits '[migrator] ambiguous legacy store; refusing
transplant' and '[migrator] size sanity check refused transplant'.

Fix both locale files to quote the actual log prefix so the
journalctl grep the operator is directed to actually hits.

Addresses tiann/hapi#877 bot finding (Minor).

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

* fix(cursor): address #877 bot Minor findings (trim + boundary guard)

- Remove .trim() from canonical path before hashing: Cursor hashes
  raw workspace-path bytes; trimming a POSIX path with leading/
  trailing spaces would hash to the wrong drawer, causing a false
  canonical miss and potential ambiguity refusal.

- Add CURSOR_SESSION_ID_RE guard to listLegacyChatStoreCandidates:
  the function was exported without the same traversal-ID boundary
  check present in findLegacyChatStore. A future direct caller
  bypassing findLegacyChatStore could stat paths outside the intended
  <wsh>/<cursorSessionId>/store.db shape.

- Move CURSOR_SESSION_ID_RE declaration above both functions that
  reference it so there is no temporal-dead-zone hazard.

Addresses tiann/hapi#877 bot review Minor findings.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-11 09:11:58 +08:00
committed by GitHub
co-authored by Cursor
parent 434cd9021d
commit 3e2e48222a
12 changed files with 932 additions and 43 deletions
+345 -3
View File
@@ -32,7 +32,15 @@ import { tmpdir } from 'node:os'
import type { Metadata } from '@hapi/protocol/schemas'
import type { Session } from '@hapi/protocol/types'
import type { AcpRpcResponse } from './acpVerifyProbe'
import { CursorLegacyMigrator, findLegacyChatStore, readLegacyMetaLastUsedModel } from './cursorLegacyMigrator'
import {
AmbiguousLegacyStoreError,
CursorLegacyMigrator,
countLegacyStoreBlobs,
findLegacyChatStore,
listLegacyChatStoreCandidates,
readLegacyMetaLastUsedModel,
workspaceHashFromPath
} from './cursorLegacyMigrator'
import { buildSyntheticLegacyStore } from './fixtures/buildSyntheticLegacyStore'
/* ---------- mock probe ---------- */
@@ -197,7 +205,7 @@ function cleanupHarness(h: Harness): void {
try { rmSync(h.tmp, { recursive: true, force: true }) } catch {}
}
function makeMigrator(h: Harness, probe: ReturnType<typeof makeMockProbe> | null, opts: { archiveSession?: (id: string) => Promise<void>; updateOverride?: (sessionId: string, namespace: string, lastUsedModel: string | null) => { ok: true } | { ok: false; reason: 'version_mismatch_or_missing' } | { ok: false; reason: 'session_active' }; isAgentAcpTransportActive?: () => { active: boolean; holderPid: number | null }; getCurrentSession?: (sessionId: string, namespace: string) => { active: boolean; lifecycleState?: string; cursorSessionProtocol?: string } | null; acquireAcpActiveLock?: () => { release(): void } | null; checkpointLegacyStore?: (storeDbPath: string) => void } = {}): CursorLegacyMigrator {
function makeMigrator(h: Harness, probe: ReturnType<typeof makeMockProbe> | null, opts: { archiveSession?: (id: string) => Promise<void>; updateOverride?: (sessionId: string, namespace: string, lastUsedModel: string | null) => { ok: true } | { ok: false; reason: 'version_mismatch_or_missing' } | { ok: false; reason: 'session_active' }; isAgentAcpTransportActive?: () => { active: boolean; holderPid: number | null }; getCurrentSession?: (sessionId: string, namespace: string) => { active: boolean; lifecycleState?: string; cursorSessionProtocol?: string } | null; acquireAcpActiveLock?: () => { release(): void } | null; checkpointLegacyStore?: (storeDbPath: string) => void; getHapiMessageCount?: (sessionId: string, namespace: string) => number } = {}): CursorLegacyMigrator {
return new CursorLegacyMigrator({}, {
homeDir: () => h.home,
hostName: () => 'h', // matches the test sessions' metadata.host
@@ -227,7 +235,8 @@ function makeMigrator(h: Harness, probe: ReturnType<typeof makeMockProbe> | null
updateSessionAfterMigrate: opts.updateOverride ?? ((sessionId, namespace, lastUsedModel) => {
h.updateCalls.push({ sessionId, namespace, lastUsedModel })
return { ok: true }
})
}),
getHapiMessageCount: opts.getHapiMessageCount
})
}
@@ -263,6 +272,129 @@ describe('findLegacyChatStore', () => {
const found = findLegacyChatStore('uuid-b', h.home)
expect(found?.workspaceHash).toBe('wsh-b')
})
// tiann/hapi#872 — path-priority + ambiguity behaviour added to guard
// against the #844 regression where the same cursorSessionId in 2+
// workspace-hash drawers silently picked the first readdir match.
it('regression guard: single drawer still resolves with no canonical-path hint', () => {
h.placeLegacyStore('reg-uuid', { workspaceHash: 'wsh-only' })
const found = findLegacyChatStore('reg-uuid', h.home)
expect(found?.workspaceHash).toBe('wsh-only')
})
it('canonical workspace path wins over any readdir-order match (tiann/hapi#872)', () => {
const canonical = '/coding/hapi'
const canonicalHash = workspaceHashFromPath(canonical)
// Plant the SAME cursorSessionId under three workspace-hash drawers.
// The canonical hash for `canonical` is one of them; the other two
// are stale siblings.
h.placeLegacyStore('same-uuid', { workspaceHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' })
h.placeLegacyStore('same-uuid', { workspaceHash: canonicalHash })
h.placeLegacyStore('same-uuid', { workspaceHash: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz' })
const found = findLegacyChatStore('same-uuid', h.home, canonical)
expect(found).not.toBeNull()
expect(found?.workspaceHash).toBe(canonicalHash)
})
it('throws AmbiguousLegacyStoreError when 3+ drawers exist and no canonical match (tiann/hapi#872)', () => {
h.placeLegacyStore('amb-uuid', { workspaceHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' })
h.placeLegacyStore('amb-uuid', { workspaceHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' })
h.placeLegacyStore('amb-uuid', { workspaceHash: 'cccccccccccccccccccccccccccccccc' })
// Pass a canonical workspace path that does NOT correspond to any
// of the planted drawers — falls through to readdir scan.
let caught: unknown
try {
findLegacyChatStore('amb-uuid', h.home, '/some/unrelated/cwd')
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(AmbiguousLegacyStoreError)
const err = caught as AmbiguousLegacyStoreError
expect(err.cursorSessionId).toBe('amb-uuid')
expect(err.candidates).toHaveLength(3)
const hashes = err.candidates.map((c) => c.workspaceHash).sort()
expect(hashes).toEqual([
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
'cccccccccccccccccccccccccccccccc'
])
for (const c of err.candidates) {
expect(typeof c.sizeBytes).toBe('number')
expect(c.sizeBytes).toBeGreaterThan(0)
expect(typeof c.mtimeMs).toBe('number')
}
})
it('throws AmbiguousLegacyStoreError when 2+ drawers exist and no canonical path is supplied (tiann/hapi#872)', () => {
h.placeLegacyStore('amb-noarg', { workspaceHash: 'wsh-1-noarg' })
h.placeLegacyStore('amb-noarg', { workspaceHash: 'wsh-2-noarg' })
let caught: unknown
try {
findLegacyChatStore('amb-noarg', h.home)
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(AmbiguousLegacyStoreError)
expect((caught as AmbiguousLegacyStoreError).candidates).toHaveLength(2)
})
it('canonical-path hint resolves cleanly even when ambiguity exists', () => {
const canonical = '/workspace/canon'
const canonicalHash = workspaceHashFromPath(canonical)
h.placeLegacyStore('amb-resolved', { workspaceHash: 'wsh-stale-1' })
h.placeLegacyStore('amb-resolved', { workspaceHash: canonicalHash })
h.placeLegacyStore('amb-resolved', { workspaceHash: 'wsh-stale-2' })
expect(() => findLegacyChatStore('amb-resolved', h.home, canonical)).not.toThrow()
const found = findLegacyChatStore('amb-resolved', h.home, canonical)
expect(found?.workspaceHash).toBe(canonicalHash)
})
it('listLegacyChatStoreCandidates enumerates every drawer (used by transplant diagnostic log)', () => {
h.placeLegacyStore('list-uuid', { workspaceHash: 'wsh-1' })
h.placeLegacyStore('list-uuid', { workspaceHash: 'wsh-2' })
const candidates = listLegacyChatStoreCandidates('list-uuid', h.home)
expect(candidates.map((c) => c.workspaceHash).sort()).toEqual(['wsh-1', 'wsh-2'])
for (const c of candidates) {
expect(c.sizeBytes).toBeGreaterThan(0)
}
})
it('workspaceHashFromPath returns a 32-char lowercase hex md5', () => {
const hash = workspaceHashFromPath('/coding/hapi')
expect(hash).toMatch(/^[0-9a-f]{32}$/)
})
/**
* Pins the algorithm contract: workspace-hash is plain md5 of the raw
* absolute path bytes, no normalization. Reference values were
* independently computed via `printf '%s' <path> | md5sum`. A future
* refactor that adds path.resolve() or trims trailing slashes would
* change these and silently break Cursor's drawer naming - Cursor
* uses raw md5 on whatever absolute path the session was opened
* under. tiann/hapi#873 cold review.
*/
it('workspaceHashFromPath matches independently-computed md5 (raw, no normalization)', () => {
// Reference values computed via `printf '%s' <path> | md5sum`.
expect(workspaceHashFromPath('/home/user/project')).toBe('90722f2638004be06d790eaac9ac1f8a')
expect(workspaceHashFromPath('/workspace/example')).toBe('56512a070a25878a45bf0c1a46021ad9')
expect(workspaceHashFromPath('/tmp/x')).toBe('7ae3976faedb45a92335f73e4d7bb9e5')
// Trailing slash MUST yield a different hash (else /foo and /foo/
// would collide on disk, which Cursor's layout does not allow).
const noSlash = workspaceHashFromPath('/workspace/example')
const withSlash = workspaceHashFromPath('/workspace/example/')
expect(noSlash).not.toBe(withSlash)
})
it('rejects path-traversal cursorSessionId inputs at the function boundary (tiann/hapi#872 cold review)', () => {
// External callers may bypass preflightSession; the function must
// not statSync arbitrary paths when fed a malformed id. All of the
// following must return null (and never throw / never probe).
for (const id of ['..', '.', '../etc', '/etc/passwd', 'a/b', 'a/../b']) {
expect(findLegacyChatStore(id, h.home, '/coding/hapi')).toBeNull()
expect(findLegacyChatStore(id, h.home)).toBeNull()
}
})
})
describe('readLegacyMetaLastUsedModel', () => {
@@ -656,6 +788,216 @@ describe('CursorLegacyMigrator.migrateOne — happy path', () => {
})
})
describe('CursorLegacyMigrator.migrateOne — ambiguous source store (tiann/hapi#872)', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
afterEach(() => cleanupHarness(h))
it('canonical workspace path on session.metadata.path picks the right drawer when others have the same uuid', async () => {
const cursorSessionId = 'pick-canonical-uuid'
const canonicalCwd = '/workspace/example'
const canonicalHash = workspaceHashFromPath(canonicalCwd)
// Plant the REAL store under the canonical drawer; siblings have
// smaller decoy stores.
const realStore = h.placeLegacyStore(cursorSessionId, { workspaceHash: canonicalHash, name: 'real chat' })
h.placeLegacyStore(cursorSessionId, { workspaceHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' })
h.placeLegacyStore(cursorSessionId, { workspaceHash: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz' })
const session = h.makeSession({
metadata: { path: canonicalCwd, host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {})
expect(out.ok).toBe(true)
if (!out.ok) return
// Real store removed (proves the canonical drawer was the source).
expect(existsSync(realStore)).toBe(false)
// ACP target placed and intact.
expect(existsSync(join(h.acpSessionsDir, cursorSessionId, 'store.db'))).toBe(true)
})
it('refuses ambiguous_legacy_store when 3 drawers exist and no canonical path matches', async () => {
const cursorSessionId = 'ambig-uuid'
const storeA = h.placeLegacyStore(cursorSessionId, { workspaceHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' })
const storeB = h.placeLegacyStore(cursorSessionId, { workspaceHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' })
const storeC = h.placeLegacyStore(cursorSessionId, { workspaceHash: 'cccccccccccccccccccccccccccccccc' })
const session = h.makeSession({
// canonical path does NOT hash to any of the planted drawers
metadata: { path: '/workspace/unrelated', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('ambiguous_legacy_store')
expect(out.message).toContain('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
expect(out.message).toContain('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')
expect(out.message).toContain('cccccccccccccccccccccccccccccccc')
// No transplant happened.
expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false)
// All three sources untouched.
expect(existsSync(storeA)).toBe(true)
expect(existsSync(storeB)).toBe(true)
expect(existsSync(storeC)).toBe(true)
})
it('proceeds when 3 drawers exist but canonical path resolves to one of them', async () => {
const cursorSessionId = 'ambig-resolved-uuid'
const canonicalCwd = '/workspace/resolved'
const canonicalHash = workspaceHashFromPath(canonicalCwd)
const realStore = h.placeLegacyStore(cursorSessionId, { workspaceHash: canonicalHash })
h.placeLegacyStore(cursorSessionId, { workspaceHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' })
h.placeLegacyStore(cursorSessionId, { workspaceHash: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz' })
const session = h.makeSession({
metadata: { path: canonicalCwd, host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {})
expect(out.ok).toBe(true)
// Only the canonical drawer's source got removed; the other two siblings stay.
expect(existsSync(realStore)).toBe(false)
})
})
describe('CursorLegacyMigrator.migrateOne — size sanity (tiann/hapi#872)', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
afterEach(() => cleanupHarness(h))
it('refuses with size_mismatch when HAPI has > 100 messages and candidate has <messageCount/4 blobs', async () => {
const cursorSessionId = 'sm-tiny-uuid'
// Synthetic store has a tiny number of blobs (single seed row).
const sourceStore = h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
getHapiMessageCount: () => 6000
}).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('size_mismatch')
expect(out.message).toMatch(/HAPI tracks 6000 message/)
// Source untouched, no ACP placement.
expect(existsSync(sourceStore)).toBe(true)
expect(existsSync(join(h.acpSessionsDir, cursorSessionId))).toBe(false)
})
it('proceeds when candidate blob count meets the messageCount/4 floor', async () => {
const cursorSessionId = 'sm-big-uuid'
const sourceStore = h.placeLegacyStore(cursorSessionId)
// Pad the source store's blobs table to clear the floor. The
// synthetic store ships `blobs(id TEXT PRIMARY KEY, data BLOB)`
// with zero rows; insert enough decoy rows to satisfy the
// migrator's sanity check without changing the on-disk layout
// in any way the migrator cares about.
const Database = require('bun:sqlite').Database
const padDb = new Database(sourceStore, { readwrite: true })
try {
padDb.exec('BEGIN')
const stmt = padDb.prepare('INSERT INTO blobs (id, data) VALUES (?, ?)')
for (let i = 0; i < 200; i += 1) {
stmt.run(`pad-${i}-${Math.random()}`, Buffer.from([0]))
}
padDb.exec('COMMIT')
} finally {
padDb.close()
}
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
getHapiMessageCount: () => 600 // floor = 150; padded blobs > 200
}).migrateOne(session, {})
expect(out.ok).toBe(true)
if (!out.ok) return
// Sanity: source removed, target placed.
expect(existsSync(sourceStore)).toBe(false)
expect(existsSync(join(h.acpSessionsDir, cursorSessionId, 'store.db'))).toBe(true)
})
it('skips the sanity check when HAPI message count is 0 (brand new session)', async () => {
const cursorSessionId = 'sm-zero-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
getHapiMessageCount: () => 0
}).migrateOne(session, {})
expect(out.ok).toBe(true)
})
it('skips the sanity check when the getHapiMessageCount dep is not wired', async () => {
const cursorSessionId = 'sm-nodep-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
// No getHapiMessageCount override → dep undefined → check disabled.
const out = await makeMigrator(h, makeMockProbe()).migrateOne(session, {})
expect(out.ok).toBe(true)
})
it('skips the sanity check when getHapiMessageCount throws (fail-open)', async () => {
const cursorSessionId = 'sm-throws-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
getHapiMessageCount: () => { throw new Error('store unreadable') }
}).migrateOne(session, {})
expect(out.ok).toBe(true)
})
it('skips the sanity check when message count is exactly 100 (boundary; floor only kicks in above 100)', async () => {
const cursorSessionId = 'sm-boundary-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
getHapiMessageCount: () => 100
}).migrateOne(session, {})
expect(out.ok).toBe(true)
})
it('engages the sanity check when message count is 101 (first value above the skip threshold)', async () => {
// The synthetic store has only a handful of blobs (well under
// 101/4 = 25). messageCount=101 is the first value that lets the
// floor kick in - this test pins the boundary contract so a
// future refactor that moves the cutoff to >=100 or >100 is
// caught by CI rather than a production session refusal.
// tiann/hapi#873 cold review Nit.
const cursorSessionId = 'sm-boundary-engaged-uuid'
h.placeLegacyStore(cursorSessionId)
const session = h.makeSession({
metadata: { path: '/workspace/x', host: 'h', flavor: 'cursor', cursorSessionId }
})
const out = await makeMigrator(h, makeMockProbe(), {
getHapiMessageCount: () => 101
}).migrateOne(session, {})
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.reason).toBe('size_mismatch')
})
})
describe('countLegacyStoreBlobs (tiann/hapi#872)', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
afterEach(() => cleanupHarness(h))
it('returns the blob row count for a real synthetic store', () => {
const p = h.placeLegacyStore('blob-count-uuid')
const n = countLegacyStoreBlobs(p)
expect(typeof n).toBe('number')
expect((n ?? -1) >= 0).toBe(true)
})
it('returns null when the store cannot be opened', () => {
const n = countLegacyStoreBlobs(join(h.tmp, 'does-not-exist.db'))
expect(n).toBeNull()
})
})
describe('CursorLegacyMigrator.migrateOne — rollback paths', () => {
let h: Harness
beforeEach(() => { h = makeHarness() })
+348 -18
View File
@@ -44,6 +44,7 @@
import { join, dirname } from 'node:path'
import { homedir, hostname, tmpdir } from 'node:os'
import { mkdtempSync, copyFileSync, writeFileSync, existsSync, mkdirSync, rmSync, rmdirSync, statSync, readdirSync, readFileSync, chmodSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { Database } from 'bun:sqlite'
import type { CursorMigrateOutcome, CursorMigrateRefusalReason } from '@hapi/protocol/apiTypes'
@@ -151,6 +152,20 @@ export interface CursorLegacyMigratorDeps {
logger?: { debug: (msg: string, ctx?: unknown) => void; info: (msg: string, ctx?: unknown) => void; warn: (msg: string, ctx?: unknown) => void; error: (msg: string, ctx?: unknown) => void }
/** Used to update hapi.db sessions.metadata.cursorSessionProtocol = 'acp' and session.model. */
updateSessionAfterMigrate?: (sessionId: string, namespace: string, lastUsedModel: string | null) => UpdateAfterMigrateResult
/**
* Best-effort count of messages HAPI has already synced for this session
* (read from hapi.db, see hub/src/store/messages.ts). Used to refuse a
* transplant when the candidate legacy store contains an order-of-
* magnitude fewer blobs than HAPI's known history - the canonical
* symptom of the #844 ambiguous-source regression where a sibling
* workspace-hash drawer with stale or unrelated content gets picked
* up by the readdir scan. Return 0 (or omit the dep) to disable the
* sanity check entirely (e.g. unit tests, brand new sessions).
*
* Hub injects an implementation that calls
* `store.messages.countMessages(sessionId)`. tiann/hapi#872.
*/
getHapiMessageCount?: (sessionId: string, namespace: string) => number
}
export type UpdateAfterMigrateResult =
@@ -163,6 +178,51 @@ export interface LegacyStoreLocation {
storeDbPath: string
}
/**
* One legacy store candidate found on disk for a given cursorSessionId.
* Carried inside AmbiguousLegacyStoreError so callers and operators can
* see the full picture (which workspace-hash drawer, how big, how recently
* written) rather than a silently-picked first match.
*/
export interface LegacyStoreCandidate {
workspaceHash: string
storeDbPath: string
sizeBytes: number
mtimeMs: number
}
/**
* Raised by findLegacyChatStore when the same cursorSessionId exists in
* 2+ workspace-hash drawers AND the optional canonical-path probe did not
* resolve the ambiguity. The migrator translates this to an
* `ambiguous_legacy_store` refusal outcome so the caller can surface a
* banner ("manually resolve") instead of transplanting an alien store.
*
* The first-match-wins behaviour shipped in #844 is exactly the bug we
* are guarding against here - see tiann/hapi#872 for the postmortem.
*/
export class AmbiguousLegacyStoreError extends Error {
public readonly cursorSessionId: string
public readonly candidates: ReadonlyArray<LegacyStoreCandidate>
constructor(cursorSessionId: string, candidates: ReadonlyArray<LegacyStoreCandidate>) {
const hashList = candidates.map((c) => c.workspaceHash).join(', ')
super(`cursor session ${cursorSessionId} exists in ${candidates.length} workspace-hash drawers and the canonical workspace path did not resolve to one of them: ${hashList}`)
this.name = 'AmbiguousLegacyStoreError'
this.cursorSessionId = cursorSessionId
this.candidates = candidates
}
}
/**
* Compute the cursor workspace-hash for a cwd path. Cursor stores legacy
* chats under `~/.cursor/chats/<md5(workspacePath)>/...`; this lets us
* jump straight to the right drawer for the session's canonical path
* instead of relying on readdir order. tiann/hapi#872.
*/
export function workspaceHashFromPath(workspacePath: string): string {
return createHash('md5').update(workspacePath).digest('hex')
}
/* ---------- helpers ---------- */
const DEFAULT_VERIFY_PROMPT = 'Reply with exactly: ack'
@@ -182,32 +242,131 @@ function refusal(sessionId: string, reason: CursorMigrateRefusalReason, message:
/* ---------- public API ---------- */
/**
* Resolve the on-disk legacy ~/.cursor/chats/<wsh>/<cursorSessionId>/store.db
* for the given cursorSessionId. Scans workspace-hash dirs because HAPI does
* not record the workspace-hash; it is hashed from the original cwd by Cursor
* and not exposed via the agent CLI.
* UUID-ish pattern: a cursor session id MUST be a basename that cannot
* escape the chats/acp-sessions trees via path traversal.
* Moved here so findLegacyChatStore and listLegacyChatStoreCandidates
* can both reference it without a temporal-dead-zone hazard.
* tiann/hapi#877 bot Minor.
*/
export function findLegacyChatStore(cursorSessionId: string, home: string): LegacyStoreLocation | null {
const CURSOR_SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/
/**
* Resolve the on-disk legacy ~/.cursor/chats/<wsh>/<cursorSessionId>/store.db
* for the given cursorSessionId.
*
* Cursor stores legacy chats under `~/.cursor/chats/<md5(workspacePath)>/...`,
* keyed by the *cwd* the session was opened in. A single cursorSessionId can
* land in several `<wsh>` drawers in the wild - e.g. when the same chat was
* resumed from a worktree, a sibling workspace, or a diagnostic location. The
* original #844 implementation iterated readdir and returned the first match;
* tiann/hapi#872 documents how that silently transplanted alien content
* over the real store on resume.
*
* The resolution order here is:
* 1. If `sessionWorkspacePath` is provided, hash it and check that drawer
* first. If a store.db exists there, return immediately. This is the
* "we know which cwd you opened it from" fast path.
* 2. Otherwise (no canonical path, or no canonical-drawer match), scan
* every `<wsh>` directory and collect every candidate.
* - 0 candidates -> null (caller surfaces no_legacy_store_on_disk).
* - 1 candidate -> return it (regression-equivalent of pre-#872 single-
* drawer path).
* - 2+ candidates -> throw AmbiguousLegacyStoreError listing every
* candidate with its hash, size, and mtime so the
* caller can surface an actionable refusal banner
* instead of picking one and transplanting blindly.
*
* tiann/hapi#872.
*/
export function findLegacyChatStore(
cursorSessionId: string,
home: string,
sessionWorkspacePath?: string
): LegacyStoreLocation | null {
// tiann/hapi#872 cold review (#34-N): findLegacyChatStore is exported
// public API and used as a free function in unit tests + the migrator
// class. Validate the id at the boundary so an out-of-band caller
// cannot pass `..` or `/` and have the inner `join(chatsRoot, wsh, id,
// 'store.db')` resolve to an arbitrary on-disk path. The probe is
// read-only (`statSync`) so blast radius is small, but the same
// CURSOR_SESSION_ID_RE preflightSession applies to in-class callers
// is cheap to also enforce here.
if (!CURSOR_SESSION_ID_RE.test(cursorSessionId) || cursorSessionId === '.' || cursorSessionId === '..') {
return null
}
const chatsRoot = join(home, '.cursor', 'chats')
if (!existsSync(chatsRoot)) return null
// Step 1: canonical-path fast path. Skip readdir entirely if we hit.
// Do NOT trim: Cursor hashes the raw workspace path bytes.
// Trimming would produce a different hash for a valid POSIX path
// whose bytes happen to begin or end with ASCII space, causing a
// canonical miss and a potential false ambiguity refusal.
// tiann/hapi#877 bot Minor.
const canonicalPath = typeof sessionWorkspacePath === 'string' ? sessionWorkspacePath : ''
if (canonicalPath.length > 0) {
const canonicalHash = workspaceHashFromPath(canonicalPath)
const canonicalCandidate = join(chatsRoot, canonicalHash, cursorSessionId, 'store.db')
try {
const st = statSync(canonicalCandidate)
if (st.isFile()) {
return { workspaceHash: canonicalHash, storeDbPath: canonicalCandidate }
}
} catch {
// canonical drawer absent or unreadable; fall through to scan
}
}
// Step 2: scan every <wsh>/<cursorSessionId>/store.db.
const candidates = listLegacyChatStoreCandidates(cursorSessionId, home)
if (candidates.length === 0) return null
if (candidates.length === 1) {
const only = candidates[0]
return { workspaceHash: only.workspaceHash, storeDbPath: only.storeDbPath }
}
throw new AmbiguousLegacyStoreError(cursorSessionId, candidates)
}
/**
* Enumerate every on-disk legacy candidate for a cursorSessionId. Pure scan,
* never throws. Used by findLegacyChatStore for the readdir fallback and by
* the migrator for the `migrator:transplanted` diagnostic log so operators
* can see "1 of N candidates picked" after the fact. tiann/hapi#872.
*/
export function listLegacyChatStoreCandidates(cursorSessionId: string, home: string): LegacyStoreCandidate[] {
// Guard: same boundary check as findLegacyChatStore. A future direct
// caller that skips findLegacyChatStore must not be able to stat paths
// outside the intended <wsh>/<cursorSessionId>/store.db shape by passing
// a traversal-like id. tiann/hapi#877 bot Minor.
if (!CURSOR_SESSION_ID_RE.test(cursorSessionId) || cursorSessionId === '.' || cursorSessionId === '..') {
return []
}
const chatsRoot = join(home, '.cursor', 'chats')
if (!existsSync(chatsRoot)) return []
let entries: string[]
try {
entries = readdirSync(chatsRoot)
} catch {
return null
return []
}
const candidates: LegacyStoreCandidate[] = []
for (const wsh of entries) {
const candidate = join(chatsRoot, wsh, cursorSessionId, 'store.db')
try {
const st = statSync(candidate)
if (st.isFile()) {
return { workspaceHash: wsh, storeDbPath: candidate }
candidates.push({
workspaceHash: wsh,
storeDbPath: candidate,
sizeBytes: st.size,
mtimeMs: st.mtimeMs
})
}
} catch {
// not in this wsh; keep scanning
}
}
return null
return candidates
}
/**
@@ -237,6 +396,27 @@ export function readLegacyMetaLastUsedModel(storeDbPath: string): { name?: strin
}
}
/**
* Read the row count of the `blobs` table from a legacy/ACP cursor store.db.
* Returns null when the file cannot be opened, the table is missing, or
* the read otherwise fails - callers should treat null as "no signal" and
* skip any blob-count-based decisions rather than treating it as a hard
* zero. tiann/hapi#872.
*/
export function countLegacyStoreBlobs(storeDbPath: string): number | null {
let db: Database | null = null
try {
db = new Database(storeDbPath, { readonly: true })
const row = db.prepare('SELECT COUNT(*) AS n FROM blobs').get() as { n?: number } | undefined
if (!row || typeof row.n !== 'number' || !Number.isFinite(row.n)) return null
return row.n
} catch {
return null
} finally {
try { db?.close() } catch {}
}
}
function decodeMetaValue(value: string): Record<string, unknown> | null {
// Try JSON first (newer ACP stores)
if (value.startsWith('{')) {
@@ -255,13 +435,6 @@ function decodeMetaValue(value: string): Record<string, unknown> | null {
return null
}
/**
* UUID-ish pattern: a cursor session id MUST be a basename that cannot
* escape the chats/acp-sessions trees via path traversal. Codex review #34
* P2: validate before any join().
*/
const CURSOR_SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/
/**
* Pre-flight: return null if the session can be migrated; return a refusal
* outcome if it cannot. Pure: no side effects.
@@ -302,7 +475,7 @@ export function preflightSession(session: Session | undefined, now: () => number
export class CursorLegacyMigrator {
private readonly opts: Required<Pick<CursorLegacyMigratorOptions, 'lockReleaseTimeoutMs' | 'verifyTimeoutMs' | 'verifyPromptText'>>
private readonly deps: Required<Pick<CursorLegacyMigratorDeps, 'homeDir' | 'hostName' | 'createProbe' | 'tmpDir' | 'now' | 'awaitLockRelease' | 'isAgentAcpTransportActive' | 'getCurrentSession' | 'logger' | 'acquireAcpActiveLock' | 'checkpointLegacyStore'>>
& Pick<CursorLegacyMigratorDeps, 'archiveSession' | 'updateSessionAfterMigrate'>
& Pick<CursorLegacyMigratorDeps, 'archiveSession' | 'updateSessionAfterMigrate' | 'getHapiMessageCount'>
constructor(opts: CursorLegacyMigratorOptions, deps: CursorLegacyMigratorDeps) {
this.opts = {
@@ -345,7 +518,8 @@ export class CursorLegacyMigrator {
getCurrentSession: deps.getCurrentSession ?? (() => null),
logger: deps.logger ?? noopLogger(),
archiveSession: deps.archiveSession,
updateSessionAfterMigrate: deps.updateSessionAfterMigrate
updateSessionAfterMigrate: deps.updateSessionAfterMigrate,
getHapiMessageCount: deps.getHapiMessageCount
}
}
@@ -431,10 +605,100 @@ export class CursorLegacyMigrator {
// Locate the legacy store.db on disk BEFORE we archive. If the
// local filesystem has no such file, we have nothing to migrate
// and there's no reason to kill the session.
const legacy = findLegacyChatStore(cursorSessionId, home)
//
// Pass the session's canonical workspace path (metadata.path) so
// findLegacyChatStore can jump straight to the md5(path) drawer
// before falling back to the readdir scan. Without the canonical
// hint, a cursorSessionId that exists in multiple <workspace-hash>
// drawers would silently get the first readdir match - the #844
// regression documented in tiann/hapi#872.
const canonicalWorkspacePath = typeof cwd === 'string' ? cwd : ''
// Snapshot the full candidate set BEFORE the canonical fast-path
// resolves a single drawer. The successful transplant's diagnostic
// log captures this count, so it must reflect the pre-rm reality
// (the rm step would otherwise leave the log understating the
// number of drawers that existed at decision time, undermining
// the diagnose-from-journalctl-alone goal). tiann/hapi#873 cold
// review Minor #1.
const candidatesAtDiscovery = listLegacyChatStoreCandidates(cursorSessionId, home)
let legacy: LegacyStoreLocation | null
try {
legacy = findLegacyChatStore(cursorSessionId, home, canonicalWorkspacePath)
} catch (err) {
if (err instanceof AmbiguousLegacyStoreError) {
const summary = err.candidates
.map((c) => `${c.workspaceHash} (size=${c.sizeBytes}, mtimeMs=${c.mtimeMs})`)
.join('; ')
const canonicalHashStr = canonicalWorkspacePath.length > 0
? workspaceHashFromPath(canonicalWorkspacePath)
: '(no canonical path on session metadata)'
log.warn('[migrator] ambiguous legacy store; refusing transplant', {
sessionId: session.id,
cursorSessionId,
canonicalWorkspacePath: canonicalWorkspacePath.length > 0 ? canonicalWorkspacePath : null,
canonicalHash: canonicalHashStr,
candidates: err.candidates
})
return refusal(
session.id,
'ambiguous_legacy_store',
`legacy store ambiguous: cursorSessionId ${cursorSessionId} exists in ${err.candidates.length} workspace-hash drawers and none matched canonical workspace path md5 (${canonicalHashStr}). Candidates: ${summary}. Resolve manually before migration.`,
start,
this.deps.now
)
}
throw err
}
if (!legacy) {
return refusal(session.id, 'no_legacy_store_on_disk', `~/.cursor/chats/*/${cursorSessionId}/store.db not found under ${home}`, start, this.deps.now)
}
// Diagnostic: canonical-path lookup missed but readdir found a
// single drawer (we still proceed for regression equivalence,
// but this warrants a log because it implies our md5(path) does
// not match Cursor's drawer naming for this session - e.g. an
// operator hit a path-normalization corner case we have not
// mapped). tiann/hapi#873 cold review Major #2.
if (canonicalWorkspacePath.length > 0 && legacy.workspaceHash !== workspaceHashFromPath(canonicalWorkspacePath)) {
log.warn('[migrator] canonical-path drawer missing; falling back to single readdir candidate', {
sessionId: session.id,
cursorSessionId,
canonicalWorkspacePath,
expectedHash: workspaceHashFromPath(canonicalWorkspacePath),
pickedHash: legacy.workspaceHash
})
}
// tiann/hapi#872: source-side measurements BEFORE any destructive
// step. The `migrator:transplanted` log on the success path quotes
// these source values - capturing them AFTER the rm would either
// fail (file gone) or read the destination copy by mistake. The
// candidate-count snapshot above (`candidatesAtDiscovery`) is the
// matching pre-rm capture for the "N candidates discovered"
// diagnostic. tiann/hapi#873 cold review.
const sourceBytesAtDiscovery = (() => {
try { return statSync(legacy.storeDbPath).size } catch { return -1 }
})()
const sourceBlobCountAtDiscovery = countLegacyStoreBlobs(legacy.storeDbPath) ?? -1
// Size sanity check: even when discovery is unambiguous, the
// picked legacy store may be a stale sibling that happens to be
// the only on-disk artifact for this session id (e.g. operator
// deleted the canonical workspace and a diagnostic location is
// all that's left). If HAPI already synced a meaningful history
// for the session and the candidate store has wildly fewer blobs
// than that history, refuse rather than transplant a shrunken
// alien snapshot over the live ACP target. Skips entirely when
// HAPI message count is 0 (brand-new / never-synced session).
// tiann/hapi#872.
const sizeMismatch = this.checkSizeSanity(session, cursorSessionId, legacy.storeDbPath, log)
if (sizeMismatch) {
log.warn('[migrator] size sanity check refused transplant', {
sessionId: session.id,
cursorSessionId,
...sizeMismatch.context
})
return refusal(session.id, 'size_mismatch', sizeMismatch.message, start, this.deps.now)
}
// Pre-flight: refuse if the ACP target dir already exists. Also
// moved BEFORE archive to avoid killing a session whose target
@@ -744,6 +1008,26 @@ export class CursorLegacyMigrator {
}
}
// tiann/hapi#872: diagnostic log on every successful transplant.
// Uses pre-rm snapshots captured at discovery time so the count
// and source measurements reflect what was actually on disk, not
// what's left after the cleanup rm. Future regression of the #844
// ambiguous-source bug is diagnosable from `journalctl -u hapi-hub`
// alone, without blob-overlap forensics on the destination store.
log.info('[migrator] transplanted', {
sessionId: session.id,
cursorSessionId,
workspaceHash: legacy.workspaceHash,
candidateCount: candidatesAtDiscovery.length,
sourceBytes: sourceBytesAtDiscovery,
sourceBlobCount: sourceBlobCountAtDiscovery,
targetAcpPath: join(acpSessionDir, 'store.db'),
sourceRemoved,
canonicalHash: canonicalWorkspacePath.length > 0
? workspaceHashFromPath(canonicalWorkspacePath)
: null
})
return {
ok: true,
sessionId: session.id,
@@ -755,6 +1039,52 @@ export class CursorLegacyMigrator {
}
}
/**
* Refuse a transplant when the candidate legacy store carries
* dramatically fewer blobs than HAPI's known message history for
* the session - the canonical symptom of the #844 ambiguous-source
* regression where a stale sibling drawer gets picked up by the
* readdir scan even when no explicit ambiguity exists (e.g. the
* canonical drawer was deleted off disk leaving only a diagnostic
* sibling). Returns null when the check passes; returns a refusal
* payload otherwise. Skipped entirely when:
* - no `getHapiMessageCount` dep is wired (e.g. unit tests, CLI
* callers that don't have a store handle)
* - HAPI message count is 0 (brand-new / never-synced session)
* - candidate blob count cannot be read (treated as fail-open
* so a corrupted store still goes through the normal verify path
* and surfaces verify_load_failed there)
* Thresholds are conservative sanity floors, not exact guarantees:
* messageCount > 100 AND blobCount < messageCount / 4. tiann/hapi#872.
*/
private checkSizeSanity(
session: Session,
cursorSessionId: string,
legacyStoreDbPath: string,
log: NonNullable<CursorLegacyMigratorDeps['logger']>
): { message: string; context: Record<string, unknown> } | null {
if (!this.deps.getHapiMessageCount) return null
let messageCount: number
try {
messageCount = this.deps.getHapiMessageCount(session.id, session.namespace)
} catch (err) {
log.warn('[migrator] getHapiMessageCount threw; skipping size sanity', {
sessionId: session.id,
err: err instanceof Error ? err.message : String(err)
})
return null
}
if (!Number.isFinite(messageCount) || messageCount <= 100) return null
const blobCount = countLegacyStoreBlobs(legacyStoreDbPath)
if (blobCount === null) return null
const minExpectedBlobs = Math.floor(messageCount / 4)
if (blobCount >= minExpectedBlobs) return null
return {
message: `legacy store size mismatch: HAPI tracks ${messageCount} message(s) for session ${cursorSessionId} but candidate store has only ${blobCount} blob(s) (< messageCount/4 = ${minExpectedBlobs}). Refusing to transplant likely-alien content; resolve manually.`,
context: { messageCount, blobCount, minExpectedBlobs, legacyStoreDbPath }
}
}
/**
* Spawn `agent acp` against a temp $HOME, copy auth files, place the
* legacy store.db at <tmp>/.cursor/acp-sessions/<uuid>/store.db + meta.json,
+5
View File
@@ -15,6 +15,7 @@ import {
getImmediateQueuedLocalMessages,
countFutureScheduledBySessionIds,
countFutureScheduledLocalMessages,
countMessages,
markMessagesInvoked,
mergeSessionMessages,
copyMessageToSession as copyStoredMessageToSession,
@@ -82,6 +83,10 @@ export class MessageStore {
return countFutureScheduledBySessionIds(this.db, sessionIds, now)
}
countMessages(sessionId: string): number {
return countMessages(this.db, sessionId)
}
cancelQueuedMessage(sessionId: string, messageId: string): CancelQueuedMessageResult {
return cancelQueuedMessage(this.db, sessionId, messageId)
}
+15
View File
@@ -299,6 +299,21 @@ export function getImmediateQueuedLocalMessages(
return rows.map(toStoredMessage)
}
/**
* Total messages persisted for a session - any role, any state (including
* future-scheduled and never-invoked queued rows). Used as the
* "is this session non-trivial?" signal for the cursor migrator's size
* sanity check; intentionally broad so a session with 6 000 unread agent
* outputs and zero invoked user turns still counts as non-trivial.
* tiann/hapi#872.
*/
export function countMessages(db: Database, sessionId: string): number {
const row = db.prepare(
'SELECT COUNT(*) AS count FROM messages WHERE session_id = ?'
).get(sessionId) as { count: number } | undefined
return row?.count ?? 0
}
/** Count uninvoked local messages scheduled for a future time (session list indicator). */
export function countFutureScheduledLocalMessages(
db: Database,
+88
View File
@@ -576,6 +576,27 @@ export class SyncEngine {
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 }
},
// tiann/hapi#872: size sanity check needs to compare HAPI's known
// message history against the candidate legacy store's blob
// count. The store-handle stays on the engine; we only thread
// the count through so the migrator stays free of a direct
// hub.Store dependency.
getHapiMessageCount: (sessionId, _namespace) => {
try {
return this.store.messages.countMessages(sessionId)
} catch (err) {
// tiann/hapi#873 cold review: a silent 0 here trips
// the migrator's "skip sanity" branch and chronically
// disables the floor. Warn so a broken countMessages
// (lock contention pattern, schema drift) is visible
// in journalctl.
console.warn('[auto-migrate] countMessages threw; size sanity skipped', {
sessionId,
err: err instanceof Error ? err.message : String(err)
})
return 0
}
}
})
}
@@ -872,6 +893,41 @@ export class SyncEngine {
if (refreshed) return refreshed
return session
}
// tiann/hapi#872: ambiguous source store OR size-mismatch
// means the migrator refused to transplant likely-alien
// content. Surface this to the UI banner instead of silently
// clearing the in-progress flag, so the operator can act
// (verify which workspace-hash drawer holds the real history,
// delete the stale siblings, retry) rather than have us
// silently fall back to the legacy launcher and pretend the
// ambiguity never happened.
if (outcome.reason === 'ambiguous_legacy_store' || outcome.reason === 'size_mismatch') {
console.warn('[auto-migrate] refusing to transplant; surfacing ambiguous banner', {
sessionId: session.id,
reason: outcome.reason,
message: outcome.message
})
const promoted = this.setCursorMigrationStateAmbiguous(session.id, namespace)
if (promoted) {
// We replaced the in-progress flag with the
// ambiguous flag; the cleanup write below would
// wipe both, so suppress it.
bannerCleanupNeeded = false
} else {
// Promotion to 'ambiguous' failed (cache miss, repeated
// version-mismatch, or non-version write failure). The
// operator-facing warning above already fired; the
// finally{} block will fall through to clear the
// in-progress flag so the user is not left with a
// permanent "Upgrading..." banner. Log so the gap is
// diagnosable from journalctl. tiann/hapi#872.
console.warn('[auto-migrate] failed to promote cursorMigrationState to "ambiguous"; banner will clear via cleanup', {
sessionId: session.id,
reason: outcome.reason
})
}
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,
@@ -894,6 +950,38 @@ export class SyncEngine {
return session
}
/**
* Replace `cursorMigrationState='in_progress'` with `'ambiguous'` so
* the web banner can switch from "Upgrading..." to "Manual resolution
* needed". Returns true if the new flag persisted. tiann/hapi#872.
*/
private setCursorMigrationStateAmbiguous(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 === 'ambiguous') return true
const nextMetadata = { ...latest.metadata, cursorMigrationState: 'ambiguous' 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
}
/**
* Set `metadata.cursorMigrationState='in_progress'` on the session row
* with a single retry on version-mismatch. Returns true if the flag was
@@ -273,6 +273,40 @@ describe('SyncEngine.maybeAutoMigrateLegacyCursorSession', () => {
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('in_progress')
})
// tiann/hapi#872: on ambiguous_legacy_store / size_mismatch
// refusals the helper must REPLACE the in-progress flag with
// 'ambiguous' (not clear it) so the web banner can surface an
// actionable state instead of silently disappearing.
it('promotes cursorMigrationState to "ambiguous" on ambiguous_legacy_store refusal (tiann/hapi#872)', async () => {
const session = insertLegacy('session-ambiguous-banner')
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
migrateOne: async (s: Session) => ({
ok: false,
sessionId: s.id,
reason: 'ambiguous_legacy_store',
message: '3 candidates found',
durationMs: 1
})
})
await callHelper(session)
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('ambiguous')
})
it('promotes cursorMigrationState to "ambiguous" on size_mismatch refusal (tiann/hapi#872)', async () => {
const session = insertLegacy('session-size-mismatch-banner')
;(engine as unknown as { buildMigratorForRequest: (req: unknown) => unknown }).buildMigratorForRequest = () => ({
migrateOne: async (s: Session) => ({
ok: false,
sessionId: s.id,
reason: 'size_mismatch',
message: 'candidate has 19 blobs vs 6000 messages',
durationMs: 1
})
})
await callHelper(session)
expect(getStoredMetadata(session.id)?.cursorMigrationState).toBe('ambiguous')
})
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
+2
View File
@@ -179,6 +179,8 @@ export type CursorMigrateRefusalReason =
| 'session_resumed_during_migrate'
| 'legacy_store_modified_during_migrate'
| 'cross_host_session'
| 'ambiguous_legacy_store'
| 'size_mismatch'
| 'internal_error'
export const UploadFileRequestSchema = z.object({
+7 -1
View File
@@ -39,7 +39,13 @@ 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(),
// Drives the web `CursorMigrationBanner`:
// 'in_progress' = legacy-to-ACP transplant currently running; banner shows spinner + "Upgrading..."
// 'ambiguous' = migrator refused to transplant (ambiguous source drawer OR size mismatch);
// banner switches to "Manual review needed" until the operator resolves on disk.
// undefined = no migration in flight; banner hidden.
// tiann/hapi#873.
cursorMigrationState: z.enum(['in_progress', 'ambiguous']).optional(),
kimiSessionId: z.string().optional(),
tools: z.array(z.string()).optional(),
slashCommands: z.array(z.string()).optional(),
@@ -1,7 +1,7 @@
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 { CursorMigrationBanner, isCursorMigrationAmbiguous, isCursorMigrationInProgress } from './CursorMigrationBanner'
import type { Metadata } from '@/types/api'
function renderWithProviders(ui: React.ReactElement) {
@@ -83,4 +83,31 @@ describe('CursorMigrationBanner', () => {
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-live', 'polite')
})
/**
* tiann/hapi#873: when the migrator refuses to transplant a legacy
* store (ambiguous source or size mismatch), the hub promotes
* cursorMigrationState to 'ambiguous'. The banner must switch to a
* "manual review needed" surface rather than disappear.
*/
it('renders the ambiguous banner when cursorMigrationState is ambiguous', () => {
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorMigrationState: 'ambiguous' })} />)
expect(screen.getByTestId('cursor-migration-banner-ambiguous')).toBeInTheDocument()
expect(screen.getByText('Cursor session upgrade needs manual review')).toBeInTheDocument()
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
})
it('uses role=alert on the ambiguous banner so it surfaces over the in-progress styling', () => {
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorMigrationState: 'ambiguous' })} />)
const alert = screen.getByRole('alert')
expect(alert).toHaveAttribute('aria-live', 'polite')
})
it('isCursorMigrationAmbiguous returns true only for the ambiguous state', () => {
expect(isCursorMigrationAmbiguous(metadata({ cursorMigrationState: 'ambiguous' }))).toBe(true)
expect(isCursorMigrationAmbiguous(metadata({ cursorMigrationState: 'in_progress' }))).toBe(false)
expect(isCursorMigrationAmbiguous(metadata())).toBe(false)
expect(isCursorMigrationAmbiguous(null)).toBe(false)
expect(isCursorMigrationAmbiguous(undefined)).toBe(false)
})
})
+56 -20
View File
@@ -30,29 +30,65 @@ export function isCursorMigrationInProgress(metadata: Metadata | undefined | nul
return metadata.cursorMigrationState === 'in_progress'
}
/**
* tiann/hapi#873: the migrator refused to transplant a legacy store -
* either because the same cursorSessionId exists in multiple workspace-hash
* drawers (`ambiguous_legacy_store`) or because the candidate's blob count
* is dramatically lower than HAPI's known history (`size_mismatch`). The
* hub promotes `cursorMigrationState` from 'in_progress' to 'ambiguous' so
* this banner can switch from "Upgrading..." to a "manual review needed"
* surface instead of disappearing silently.
*/
export function isCursorMigrationAmbiguous(metadata: Metadata | undefined | null): boolean {
if (!metadata) return false
return metadata.cursorMigrationState === 'ambiguous'
}
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')}
if (isCursorMigrationInProgress(metadata)) {
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>
</div>
)
)
}
if (isCursorMigrationAmbiguous(metadata)) {
return (
<div className="px-3 pt-3" data-testid="cursor-migration-banner-ambiguous">
<div
role="alert"
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 rounded-full border-2 border-current"
>!</span>
<div className="min-w-0 flex-1">
<div className="font-medium">{t('session.cursorMigration.bannerAmbiguous.title')}</div>
<div className="text-xs text-[var(--app-hint)]">
{t('session.cursorMigration.bannerAmbiguous.body')}
</div>
</div>
</div>
</div>
)
}
return null
}
+2
View File
@@ -120,6 +120,8 @@ export default {
'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.cursorMigration.bannerAmbiguous.title': 'Cursor session upgrade needs manual review',
'session.cursorMigration.bannerAmbiguous.body': 'This chat exists on disk in multiple workspace folders or the on-disk size does not match the synced history, so HAPI refused to transplant it automatically. Check hub logs for the candidate list (search for "[migrator] ambiguous legacy store" or "[migrator] size sanity check refused"), delete the stale drawers under ~/.cursor/chats/, and reopen the session.',
// Session inactive
'session.inactive.autoResume': 'This session is inactive. Send a message to resume.',
+2
View File
@@ -120,6 +120,8 @@ export default {
'session.time.importedFromCodex.daysAgo': '{n} 天前从codex客户端导入',
'session.cursorMigration.banner.title': '正在升级 Cursor 会话',
'session.cursorMigration.banner.body': '正在将此旧版会话切换到更安全的 ACP 协议。历史较长的会话需要 15-20 秒;对话会自动恢复,已输入但未发送的草稿不会丢失。',
'session.cursorMigration.bannerAmbiguous.title': 'Cursor 会话升级需要人工处理',
'session.cursorMigration.bannerAmbiguous.body': '此会话在磁盘上的多个工作区下存在,或本地存储大小与已同步的历史记录不匹配,因此 HAPI 拒绝自动迁移。请在 hub 日志中搜索 "[migrator] ambiguous legacy store" 或 "[migrator] size sanity check refused" 获取候选列表,删除 ~/.cursor/chats/ 下的陈旧目录后,再重新打开此会话。',
// Session inactive
'session.inactive.autoResume': '此会话已停止。发送消息即可恢复。',