diff --git a/hub/src/cursor/cursorLegacyMigrator.test.ts b/hub/src/cursor/cursorLegacyMigrator.test.ts index 70f262af..78da0abb 100644 --- a/hub/src/cursor/cursorLegacyMigrator.test.ts +++ b/hub/src/cursor/cursorLegacyMigrator.test.ts @@ -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 | null, opts: { archiveSession?: (id: string) => Promise; updateOverride?: (sessionId: string, namespace: string, lastUsedModel: string | null) => { ok: true } | { ok: false; reason: 'version_mismatch_or_missing' } | { ok: false; reason: 'session_active' }; isAgentAcpTransportActive?: () => { active: boolean; holderPid: number | null }; getCurrentSession?: (sessionId: string, namespace: string) => { active: boolean; lifecycleState?: string; cursorSessionProtocol?: string } | null; acquireAcpActiveLock?: () => { release(): void } | null; checkpointLegacyStore?: (storeDbPath: string) => void } = {}): CursorLegacyMigrator { +function makeMigrator(h: Harness, probe: ReturnType | null, opts: { archiveSession?: (id: string) => Promise; updateOverride?: (sessionId: string, namespace: string, lastUsedModel: string | null) => { ok: true } | { ok: false; reason: 'version_mismatch_or_missing' } | { ok: false; reason: 'session_active' }; isAgentAcpTransportActive?: () => { active: boolean; holderPid: number | null }; getCurrentSession?: (sessionId: string, namespace: string) => { active: boolean; lifecycleState?: string; cursorSessionProtocol?: string } | null; acquireAcpActiveLock?: () => { release(): void } | null; checkpointLegacyStore?: (storeDbPath: string) => void; 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 | 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' | 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' | 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 { + 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() }) diff --git a/hub/src/cursor/cursorLegacyMigrator.ts b/hub/src/cursor/cursorLegacyMigrator.ts index 829a5208..d5af5e35 100644 --- a/hub/src/cursor/cursorLegacyMigrator.ts +++ b/hub/src/cursor/cursorLegacyMigrator.ts @@ -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 + constructor(cursorSessionId: string, candidates: ReadonlyArray) { + 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//...`; 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///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///store.db + * for the given cursorSessionId. + * + * Cursor stores legacy chats under `~/.cursor/chats//...`, + * keyed by the *cwd* the session was opened in. A single cursorSessionId can + * land in several `` 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 `` 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 //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 //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 | null { // Try JSON first (newer ACP stores) if (value.startsWith('{')) { @@ -255,13 +435,6 @@ function decodeMetaValue(value: string): Record | 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> private readonly deps: Required> - & Pick + & Pick 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 + // 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 + ): { message: string; context: Record } | 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 /.cursor/acp-sessions//store.db + meta.json, diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index fc862411..99060bc9 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -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) } diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index 540bdc78..bca78a0c 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -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, diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 632f29a3..a7f88c03 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -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 diff --git a/hub/src/sync/syncEngineAutoMigrate.test.ts b/hub/src/sync/syncEngineAutoMigrate.test.ts index 38753dfe..a36c6878 100644 --- a/hub/src/sync/syncEngineAutoMigrate.test.ts +++ b/hub/src/sync/syncEngineAutoMigrate.test.ts @@ -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 diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index f5591310..604e1a90 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -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({ diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 5671cbe6..22a6fd51 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -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(), diff --git a/web/src/components/CursorMigrationBanner.test.tsx b/web/src/components/CursorMigrationBanner.test.tsx index 2a32eabb..537e7489 100644 --- a/web/src/components/CursorMigrationBanner.test.tsx +++ b/web/src/components/CursorMigrationBanner.test.tsx @@ -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() + 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() + 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) + }) }) diff --git a/web/src/components/CursorMigrationBanner.tsx b/web/src/components/CursorMigrationBanner.tsx index a5d5cc66..2db9bdbe 100644 --- a/web/src/components/CursorMigrationBanner.tsx +++ b/web/src/components/CursorMigrationBanner.tsx @@ -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 ( -
-
-