mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* 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:
@@ -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() })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '此会话已停止。发送消息即可恢复。',
|
||||
|
||||
Reference in New Issue
Block a user