mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-07 19:06:11 +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>
95 lines
4.4 KiB
TypeScript
95 lines
4.4 KiB
TypeScript
/**
|
|
* CursorMigrationBanner
|
|
*
|
|
* Surfaces the in-progress automatic legacy-stream-json → ACP migration to
|
|
* the user, so the 15-20s "dark wait" while the migrator transplants the
|
|
* store.db, spawns `agent acp`, replays notifications, and tears down the
|
|
* verify probe doesn't read as "broken / nothing is happening".
|
|
*
|
|
* Visibility contract:
|
|
* - Renders when `session.metadata.cursorMigrationState === 'in_progress'`
|
|
* - Hub flips the flag → SSE `session-updated` → React Query cache → this
|
|
* re-renders within milliseconds (no client-side polling needed; the
|
|
* hub's session-updated channel is already real-time).
|
|
* - Hub clears the flag in the SAME metadata write that flips
|
|
* `cursorSessionProtocol` to 'acp' on success, so the banner disappears
|
|
* in the same render tick the chat re-renders as ACP — no flicker.
|
|
* - On failure / exception the hub clears the flag explicitly in the
|
|
* auto-migrate helper's finally, so the banner never gets stuck.
|
|
*
|
|
* Deliberately minimal — no fake progress bar (we don't have phase data and
|
|
* a fake percentage would lie); just an indeterminate spinner + a short
|
|
* explanation. UX A++ design notes are in PR #34's body.
|
|
*/
|
|
|
|
import type { Metadata } from '@/types/api'
|
|
import { useTranslation } from '@/lib/use-translation'
|
|
|
|
export function isCursorMigrationInProgress(metadata: Metadata | undefined | null): boolean {
|
|
if (!metadata) return false
|
|
return metadata.cursorMigrationState === 'in_progress'
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<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>
|
|
)
|
|
}
|
|
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
|
|
}
|