feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback#896
feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback#896heavygee wants to merge 11 commits into
Conversation
There was a problem hiding this comment.
Findings
- [Major] Migration can erase local-only notes after a partial upload - the migration loop swallows each failed POST and still writes the migrated flag; meanwhile the offline-cache effect mirrors the initial empty hub response back into
hapi.scratchlist.v1.<sessionId>, so a transient error or cap rejection can leave entries neither on the hub nor in localStorage. Evidenceweb/src/lib/use-hub-scratchlist.ts:242.
Suggested fix:const failedEntries: ScratchlistEntry[] = [] for (const entry of localEntries) { try { await api.createScratchlistEntry(sessionId, { text, entryId: entry.id, createdAt: entry.createdAt }) } catch { failedEntries.push(entry) } } if (failedEntries.length > 0) { persistScratchlist(sessionId, failedEntries) migrationAttemptedRef.current = false setMigrationStatus("idle") return } writeMigrationFlag(sessionId)
- [Major] Duplicate migration retry is blocked once the session is at the cap - the POST route checks
count >= SCRATCHLIST_MAX_ENTRIESbefore asking the store whether the suppliedentryIdalready exists, so retrying an already-created entry at 200 rows returns 409 instead of the documented idempotent 200. Evidencehub/src/web/routes/sessions.ts:661.
Suggested fix:if (parsed.data.entryId) { const existing = engine.getScratchlistEntry(sessionResult.sessionId, parsed.data.entryId) if (existing) { return c.json({ entry: existing }, 200) } } const currentCount = engine.countScratchlistEntries(sessionResult.sessionId) if (currentCount >= SCRATCHLIST_MAX_ENTRIES) { return c.json({ error: `Scratchlist is at its ${SCRATCHLIST_MAX_ENTRIES}-entry cap`, code: "scratchlist_at_cap" }, 409) }
Summary
- Review mode: initial
- Found two Major issues in the new scratchlist migration/API path: one can lose local-only migrated notes on partial upload failure, and one breaks the duplicate POST idempotency contract at the cap boundary.
Testing
- Not run (automation)
HAPI Bot
Two real data-correctness paths the bot caught on the initial review.
1. Migration partial-failure data loss
The migration loop swallowed each failed POST and still wrote the
`migrated` flag, while the offline-cache effect mirrored the
(partial) hub state back into `hapi.scratchlist.v1.<sessionId>` -
so a transient error or cap rejection could leave entries neither
on the hub nor in localStorage. Fix:
- Track failed entries during migration and persist them back to
localStorage; do NOT advance the flag if any entry failed, so a
future mount retries.
- Gate the offline-cache effect on the migration flag. Pre-
migration, localStorage holds the v1 entries the migration
reads; mirroring an empty hub fetch over them was the wipe.
- Drop the "skip migration when hub is non-empty" gate. Combined
with the duplicate-idempotent POST short-circuit (below), a
retry against a session that another device already populated
is a safe union.
2. Duplicate POST returned 409 at cap
The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking
the store whether the supplied `entryId` already existed, so an
idempotent migration retry against a 200-row session returned 409
instead of 200. Fix: check duplicate first via a new
`SyncEngine.getScratchlistEntry`, return the existing row with 200,
and only run the cap check for genuinely new ids.
Tests added:
- hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap
+ new entryId still 409.
- web/hook: partial-failure persists the failed entries back to
localStorage and leaves the flag unset; offline-cache effect does
not wipe pre-migration localStorage.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks - both Major findings were valid. Pushed Major 1 (migration data loss): the migration loop now tracks failed entries, persists them back to Major 2 (duplicate at cap returns 409): the route now checks duplicate via a new Tests added:
|
There was a problem hiding this comment.
Findings
- No issues found.
Summary
- Review mode: follow-up after new commits
- Full PR diff reviewed at the latest head. The two prior Major findings were addressed in the follow-up changes; no new high-confidence correctness, security, data-loss, performance, or maintainability issues found in the added/modified lines.
- Residual risk: web coverage is unit-focused around the hook and banner; no browser/e2e run was performed in this review pass.
Testing
- Not run (automation)
HAPI Bot
There was a problem hiding this comment.
Findings
- [Minor] Bound client-supplied scratchlist entry IDs —
entryIdis validated only as non-empty while the new scratchlist text and entry-count limits are bounded. Because POST persists this client-supplied value as part of the SQLite primary key, an authenticated/direct client can store oversized keys and grow the table/index beyond the intended scratchlist limits. Evidenceshared/src/apiTypes.ts:176.
Suggested fix:export const SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128 export const ScratchlistEntryCreateRequestSchema = z.object({ entryId: z.string().min(1).max(SCRATCHLIST_MAX_ENTRY_ID_LENGTH).optional(), text: z.string().min(1).max(SCRATCHLIST_MAX_TEXT_LENGTH), createdAt: z.number().int().nonnegative().optional() })
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full current PR diff reviewed. Prior bot findings remain addressed; one low-severity server-side validation gap remains in the scratchlist create schema.
- Residual risk: no local test run in this review pass.
Testing
- Not run (automation)
HAPI Bot
…iann#896) The POST /api/sessions/:id/scratchlist body validator left `entryId` unbounded (`z.string().min(1)`), but that string is persisted as part of the SQLite primary key. An authenticated/direct client could grow the table and its index well beyond the intended scratchlist limits by submitting oversized keys. Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a UUID's 36 chars plus any prefix scheme we might layer on later) and applies `.max(...)` to the optional `entryId` in `ScratchlistEntryCreateRequestSchema`. Anything longer is rejected with 400 before the row hits SQLite. Test pins the new behavior: a 129-char id returns 400 and never reaches the engine. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Minor] Reloading before dismissing suppresses the migration banner — successful migration writes the migrated flag, but initialization maps a migrated/not-dismissed session to
pre-migrated;ScratchlistMigrationBanneronly renders forcompleted, so a user who refreshes before clicking dismiss never sees the banner again and the dismissal flag is never required. Evidenceweb/src/lib/use-hub-scratchlist.ts:193.
Suggested fix:if (readBannerDismissed(sessionId)) return 'dismissed' if (readMigrationFlag(sessionId)) return 'completed' // No local entries migrated, so opt this session out of the banner. if (localEntries.length === 0) { writeBannerDismissed(sessionId) writeMigrationFlag(sessionId) return }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full current PR diff reviewed. The prior
entryIdvalidation finding is addressed; one minor banner state-machine issue remains. - Residual risk: no local test run in this review pass.
Testing
- Not run (automation). Suggested: add a hook test for “migration completed, reload before dismiss” so the banner remains visible until the dismiss flag is written.
HAPI Bot
…dismissed (HAPI Bot, PR tiann#896) The previous state machine swallowed the migration banner if the operator reloaded the page before clicking dismiss: the migration flag was set on success, and on remount the init logic mapped a flag-set/dismiss-not-set session to 'pre-migrated', a state the banner explicitly refuses to render. Net effect: a migrated session never prompted for affirmative dismissal. Fixes: - Drop the 'pre-migrated' state. The dismissal flag is now the only signal that suppresses the banner; the migration flag alone means 'banner shows until dismissed' (now or after a reload). - Sessions that had nothing to migrate (no v1 entries in localStorage) pre-emptively write BOTH flags - migrated AND dismissed - so the bot's banner-stickiness fix doesn't surface a banner that has nothing to announce on freshly-created v2 sessions. Tests: - New `reload-before-dismiss leaves the banner visible` test pins the fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2 on the same session reads the localStorage flags and stays 'completed'. - New `opts fresh sessions out of the banner pre-emptively` test pins the no-v1-entries shortcut. - Existing `does not re-migrate on a mount where the migrated flag is already set` updated to assert 'completed' (not the dropped 'pre-migrated'). - Existing `skips migration when localStorage is empty` updated to assert the new 'dismissed' status + the banner-dismissed flag. - Banner test for the 'pre-migrated -> nothing' case removed (the state no longer exists). Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full current PR diff reviewed. The previous banner state-machine finding is addressed in the new commits, including coverage for reload-before-dismiss and fresh-session opt-out.
- Residual risk: scratchlist sync now spans hub storage, REST, SSE invalidation, and localStorage migration, so cross-device behavior still depends on the full automation matrix passing.
Testing
- Not run (automation).
HAPI Bot
In soup integration, feat/session-list-rich-tooltips ships an internal web/src/lib/relativeTime.ts (camelCase) and v2 (soup/scratchlist-v2-v11) ships web/src/lib/relative-time.ts (kebab-case) for sharing with ScratchlistPanel's entry-age tooltip. The textual three-way merge takes both imports cleanly (no conflict markers; they're at different lines) and the TS compiler then errors with TS2300 'Duplicate identifier formatRelativeTime'. This soup-only branch (do NOT submit upstream; the camelCase file is the upstream-correct source for session-list-rich-tooltips) redirects this single import to the kebab-case path, which v2's relative-time.ts also exports via the same name. Standalone, this branch will not compile because @/lib/relative-time does not exist outside the v2-merged tree. Only valid as a soup layer applied AFTER soup/scratchlist-v2-v11. Drop this layer when EITHER session-list-rich-tooltips merges upstream (at which point we either pick a winner there or leave the dual-files in soup as long as upstream tolerates it) OR v2 (tiann#896) merges upstream (at which point session-list-rich-tooltips needs to rebase onto the kebab-case file directly). Co-authored-by: Cursor <cursoragent@cursor.com>
…e does not strand them (closes tiann#920 for v2.0) `mergeSessionData` in `sessionCache.ts` ends every merge codepath with `deleteSession(oldSessionId)`, which fires `ON DELETE CASCADE` on every FK-tied table. `session_scratchlist.session_id` is FK'd with cascade, so without an explicit transfer step every dedup (tiann#448 agent-id collision) and every resume-of-inactive (`syncEngine.resumeSession` -> mergeSessions) silently destroys the operator's per-session notes. This is the gap upstream-discovery agent flagged on tiann#920 against PR tiann#896. With the 2026-06-15 hub-restart cascade incident as evidence (23 sessions auto-archived in a single bounce, 4 confirmed HAPI-id rotations across 2 bounces), unmitigated this would violate v2.0's "survives reloads / second laptop / clear-site-data" promise the first time the operator hits a hub bounce. Fix: - New `transferScratchlistEntries(db, fromSessionId, toSessionId)` in `hub/src/store/scratchlist.ts`. Atomic via BEGIN/COMMIT. Uses `UPDATE OR IGNORE` so rows that would collide on PRIMARY KEY (session_id, entry_id) simply do not move - the dedup target's copy wins, matching the operator's mental model that the consolidated session is authoritative. Cleans up any collision-loser rows so the no-delete codepath (`mergeSessionHistory`) is symmetric with the delete path. - Wired into `mergeSessionData` BEFORE the `deleteSession()` call, alongside the existing message-merge step. Both `mergeSessions` (deleteOld=true) and `mergeSessionHistory` (deleteOld=false) get coverage because both can rotate the visible session id. - Emits `session-updated{scratchlistUpdatedAt}` on the new session so any web client looking at the consolidated id invalidates and refetches; for the keep-old codepath the emit also fires on the old id since it stays alive but is now empty of scratchlist. Tests (`sessionCache-merge-scratchlist.test.ts`, 7 cases): - mergeSessions (deleteOld=true): rows move, old is gone, no stranded rows. - mergeSessions PK collision: dedup target wins, unique-to-old rows still come across. - mergeSessions SSE: exactly one scratchlist patch on the new id. - mergeSessions no-op: zero rows -> zero emits. - mergeSessionHistory (deleteOld=false): rows move, old session stays alive but empty of scratchlist. - mergeSessionHistory SSE: emits on BOTH old and new ids. - Cascade-delete safety smoke: post-merge, an explicit operator delete of the new session DOES cascade-delete its scratchlist (i.e. the FK cascade we want is intact; the bug was triggering it on the wrong id). Web layer note: v1 localStorage is keyed by HAPI session id; on rotation the old key is orphaned but no longer represents data loss because the hub now holds the canonical state and the offline-cache mirror re-populates `hapi.scratchlist.v1.<newId>` on first read of the consolidated session. Documented as a known limitation; not a blocker for v2.0 because the hub is the source of truth. tiann#894 (v2.1 migrate-on-delete) inherits a related concern about operator-Delete vs merge-Delete consent flow - flagged in the upstream-discovery handoff, separate scope. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
Review mode: follow-up after new commits
Full current PR diff reviewed, including the prior bot context and the new session-merge scratchlist transfer delta. No high-confidence correctness, security, data-loss, or regression findings to report.
Residual risk: scratchlist sync crosses hub storage, REST, SSE invalidation, migration flags, and offline localStorage cache; local validation could not run in this runner because bun is unavailable.
Testing
- Not run:
bun typecheckandbun run testfailed becausebunwas not found in the runner PATH.
HAPI Bot
Two real data-correctness paths the bot caught on the initial review.
1. Migration partial-failure data loss
The migration loop swallowed each failed POST and still wrote the
`migrated` flag, while the offline-cache effect mirrored the
(partial) hub state back into `hapi.scratchlist.v1.<sessionId>` -
so a transient error or cap rejection could leave entries neither
on the hub nor in localStorage. Fix:
- Track failed entries during migration and persist them back to
localStorage; do NOT advance the flag if any entry failed, so a
future mount retries.
- Gate the offline-cache effect on the migration flag. Pre-
migration, localStorage holds the v1 entries the migration
reads; mirroring an empty hub fetch over them was the wipe.
- Drop the "skip migration when hub is non-empty" gate. Combined
with the duplicate-idempotent POST short-circuit (below), a
retry against a session that another device already populated
is a safe union.
2. Duplicate POST returned 409 at cap
The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking
the store whether the supplied `entryId` already existed, so an
idempotent migration retry against a 200-row session returned 409
instead of 200. Fix: check duplicate first via a new
`SyncEngine.getScratchlistEntry`, return the existing row with 200,
and only run the cap check for genuinely new ids.
Tests added:
- hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap
+ new entryId still 409.
- web/hook: partial-failure persists the failed entries back to
localStorage and leaves the flag unset; offline-cache effect does
not wipe pre-migration localStorage.
Co-authored-by: Cursor <cursoragent@cursor.com>
…iann#896) The POST /api/sessions/:id/scratchlist body validator left `entryId` unbounded (`z.string().min(1)`), but that string is persisted as part of the SQLite primary key. An authenticated/direct client could grow the table and its index well beyond the intended scratchlist limits by submitting oversized keys. Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a UUID's 36 chars plus any prefix scheme we might layer on later) and applies `.max(...)` to the optional `entryId` in `ScratchlistEntryCreateRequestSchema`. Anything longer is rejected with 400 before the row hits SQLite. Test pins the new behavior: a 129-char id returns 400 and never reaches the engine. Co-authored-by: Cursor <cursoragent@cursor.com>
1a58c80 to
063a139
Compare
…dismissed (HAPI Bot, PR tiann#896) The previous state machine swallowed the migration banner if the operator reloaded the page before clicking dismiss: the migration flag was set on success, and on remount the init logic mapped a flag-set/dismiss-not-set session to 'pre-migrated', a state the banner explicitly refuses to render. Net effect: a migrated session never prompted for affirmative dismissal. Fixes: - Drop the 'pre-migrated' state. The dismissal flag is now the only signal that suppresses the banner; the migration flag alone means 'banner shows until dismissed' (now or after a reload). - Sessions that had nothing to migrate (no v1 entries in localStorage) pre-emptively write BOTH flags - migrated AND dismissed - so the bot's banner-stickiness fix doesn't surface a banner that has nothing to announce on freshly-created v2 sessions. Tests: - New `reload-before-dismiss leaves the banner visible` test pins the fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2 on the same session reads the localStorage flags and stays 'completed'. - New `opts fresh sessions out of the banner pre-emptively` test pins the no-v1-entries shortcut. - Existing `does not re-migrate on a mount where the migrated flag is already set` updated to assert 'completed' (not the dropped 'pre-migrated'). - Existing `skips migration when localStorage is empty` updated to assert the new 'dismissed' status + the banner-dismissed flag. - Banner test for the 'pre-migrated -> nothing' case removed (the state no longer exists). Co-authored-by: Cursor <cursoragent@cursor.com>
…e does not strand them (closes tiann#920 for v2.0) `mergeSessionData` in `sessionCache.ts` ends every merge codepath with `deleteSession(oldSessionId)`, which fires `ON DELETE CASCADE` on every FK-tied table. `session_scratchlist.session_id` is FK'd with cascade, so without an explicit transfer step every dedup (tiann#448 agent-id collision) and every resume-of-inactive (`syncEngine.resumeSession` -> mergeSessions) silently destroys the operator's per-session notes. This is the gap upstream-discovery agent flagged on tiann#920 against PR tiann#896. With the 2026-06-15 hub-restart cascade incident as evidence (23 sessions auto-archived in a single bounce, 4 confirmed HAPI-id rotations across 2 bounces), unmitigated this would violate v2.0's "survives reloads / second laptop / clear-site-data" promise the first time the operator hits a hub bounce. Fix: - New `transferScratchlistEntries(db, fromSessionId, toSessionId)` in `hub/src/store/scratchlist.ts`. Atomic via BEGIN/COMMIT. Uses `UPDATE OR IGNORE` so rows that would collide on PRIMARY KEY (session_id, entry_id) simply do not move - the dedup target's copy wins, matching the operator's mental model that the consolidated session is authoritative. Cleans up any collision-loser rows so the no-delete codepath (`mergeSessionHistory`) is symmetric with the delete path. - Wired into `mergeSessionData` BEFORE the `deleteSession()` call, alongside the existing message-merge step. Both `mergeSessions` (deleteOld=true) and `mergeSessionHistory` (deleteOld=false) get coverage because both can rotate the visible session id. - Emits `session-updated{scratchlistUpdatedAt}` on the new session so any web client looking at the consolidated id invalidates and refetches; for the keep-old codepath the emit also fires on the old id since it stays alive but is now empty of scratchlist. Tests (`sessionCache-merge-scratchlist.test.ts`, 7 cases): - mergeSessions (deleteOld=true): rows move, old is gone, no stranded rows. - mergeSessions PK collision: dedup target wins, unique-to-old rows still come across. - mergeSessions SSE: exactly one scratchlist patch on the new id. - mergeSessions no-op: zero rows -> zero emits. - mergeSessionHistory (deleteOld=false): rows move, old session stays alive but empty of scratchlist. - mergeSessionHistory SSE: emits on BOTH old and new ids. - Cascade-delete safety smoke: post-merge, an explicit operator delete of the new session DOES cascade-delete its scratchlist (i.e. the FK cascade we want is intact; the bug was triggering it on the wrong id). Web layer note: v1 localStorage is keyed by HAPI session id; on rotation the old key is orphaned but no longer represents data loss because the hub now holds the canonical state and the offline-cache mirror re-populates `hapi.scratchlist.v1.<newId>` on first read of the consolidated session. Documented as a known limitation; not a blocker for v2.0 because the hub is the source of truth. tiann#894 (v2.1 migrate-on-delete) inherits a related concern about operator-Delete vs merge-Delete consent flow - flagged in the upstream-discovery handoff, separate scope. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Rebased onto upstream/main (was behind 2: #904 service_tier + #886 codex machine binding). Conflict resolution: upstream took schema V9→V10 for Open review threads: all 4 resolved - fixes were already landed in prior commits:
Latest: |
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
Review mode: follow-up after new commits
Full current PR diff reviewed, including the prior HAPI Bot review, the follow-up compare from 1a58c80cd969c4ee0e8dee2e8b746f330d383dcb to 063a1398574b8e3236aa8a07f0fd93be53aba8da, and surrounding hub/web/shared context. No high-confidence correctness, security, data-loss, performance, or maintainability findings to report.
Residual risk: scratchlist v2 spans SQLite migration, REST, SSE invalidation, session merge transfer, and localStorage migration/offline cache behavior; the diff includes focused coverage for those paths, but this review was static.
Testing
- Not run (automation): static PR review only; did not execute code from PR content.
HAPI Bot
…dated piggyback (tiann#893) Promotes scratchlist persistence from per-device localStorage to a hub- backed typed table so entries follow the operator across devices. v1 panel UI / FUE / shortcut / styling are deliberately unchanged - this is a backend + sync-layer feature. Hub side - New `session_scratchlist` typed table (sessionId, entryId, text, createdAt, updatedAt) with composite PK and FK ON DELETE CASCADE from sessions. Schema bumped V9 -> V10; idempotent migration added to the legacy + step ladders. - REST CRUD under `/api/sessions/:id/scratchlist[/:entryId]`, all routed through the existing `requireSessionFromParam` guard so namespace / ownership enforcement is identical to other session-scoped routes. - Per-session 200-entry cap enforced on POST. Duplicate entryId reported idempotently (200) so the migration retry path is safe. - `SessionPatchSchema` extended with `scratchlistUpdatedAt?: number`; every successful mutation emits a `session-updated` SSE patch with the token. (Following operator's piggyback decision; aligns with the parallel tiann#884 patch-shape extension.) Web side - Hub becomes source of truth via TanStack Query (`queryKeys.scratchlist(sessionId)`); localStorage demoted to offline cache. Add / delete / update mutations are optimistic with rollback on error. - Silent first-load migration: existing localStorage entries are pushed to the hub preserving id + createdAt, and a one-time banner (mirroring `CursorMigrationBanner`) tells the operator their notes are now in the hub. Banner dismissal is per-session and persistent. - SSE handler queues a `scratchlist` invalidation when the patch carries `scratchlistUpdatedAt`, so cross-device + cross-tab updates land within an SSE round-trip. - Delete-session confirm copy now includes a count of scratchlist entries that will be cascade-deleted. Out of scope (separate tracking issue tiann#894): "delete with summarize-and- migrate" UX flow. Tests - Hub: V9->V10 migration (fresh + multi-hop legacy + idempotent reopen + cascade-delete), `ScratchlistStore` CRUD + ordering, REST routes (happy path + 400/403/404/409), SyncEngine SSE emission. - Web: hook covers initial fetch, optimistic add/delete/update with rollback, localStorage migration + banner, cap enforcement, local-only reorder. Banner component renders only on `'completed'`. - Existing Playwright e2e (10 tests, panel UI regression) all pass unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
Two real data-correctness paths the bot caught on the initial review.
1. Migration partial-failure data loss
The migration loop swallowed each failed POST and still wrote the
`migrated` flag, while the offline-cache effect mirrored the
(partial) hub state back into `hapi.scratchlist.v1.<sessionId>` -
so a transient error or cap rejection could leave entries neither
on the hub nor in localStorage. Fix:
- Track failed entries during migration and persist them back to
localStorage; do NOT advance the flag if any entry failed, so a
future mount retries.
- Gate the offline-cache effect on the migration flag. Pre-
migration, localStorage holds the v1 entries the migration
reads; mirroring an empty hub fetch over them was the wipe.
- Drop the "skip migration when hub is non-empty" gate. Combined
with the duplicate-idempotent POST short-circuit (below), a
retry against a session that another device already populated
is a safe union.
2. Duplicate POST returned 409 at cap
The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking
the store whether the supplied `entryId` already existed, so an
idempotent migration retry against a 200-row session returned 409
instead of 200. Fix: check duplicate first via a new
`SyncEngine.getScratchlistEntry`, return the existing row with 200,
and only run the cap check for genuinely new ids.
Tests added:
- hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap
+ new entryId still 409.
- web/hook: partial-failure persists the failed entries back to
localStorage and leaves the flag unset; offline-cache effect does
not wipe pre-migration localStorage.
Co-authored-by: Cursor <cursoragent@cursor.com>
Surfaces the smart-relative time the entry was last saved on every scratchlist row, mirroring the bucketing used in the session list: just-now -> Nm -> Nh -> Nd -> absolute date. Implementation: - Extract the existing `formatRelativeTime` helper out of SessionList into `web/src/lib/relative-time.ts` so the panel can reuse the same buckets and i18n keys (no copy-paste drift between surfaces). Also add `formatAbsoluteDateTime` for the precise-stamp tooltip line. - Add `updatedAt?: number` to the local `ScratchlistEntry` shape. v1-only callers stay valid (the field is optional and `isEntry` now accepts rows that omit it). The hub hook forwards the hub's `updatedAt` so the indicator reflects edits, not just creation. - New `EntryAgeIndicator` component: clock SVG in the same style as the existing action icons, rendered inside both panel surfaces (the older `ScratchlistList` and the drawer variant). Falls back to `createdAt` when `updatedAt` is missing (legacy v1 rows during the migration window) and renders nothing if neither timestamp is usable. - Tooltip carries the relative bucket plus the absolute timestamp on a second line; aria-label carries the relative bucket only so screen readers stay terse. - Mirror `updatedAt` into the localStorage offline cache so an offline reload still has accurate ages. Tests: - `relative-time.test.ts`: bucket math, seconds-vs-ms detection, non-finite guard. - `ScratchlistPanel.test.tsx`: indicator renders with the right smart-relative bucket, falls back to `createdAt` when `updatedAt` is absent, and renders nothing when both timestamps are zero. Co-authored-by: Cursor <cursoragent@cursor.com>
…iann#896) The POST /api/sessions/:id/scratchlist body validator left `entryId` unbounded (`z.string().min(1)`), but that string is persisted as part of the SQLite primary key. An authenticated/direct client could grow the table and its index well beyond the intended scratchlist limits by submitting oversized keys. Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a UUID's 36 chars plus any prefix scheme we might layer on later) and applies `.max(...)` to the optional `entryId` in `ScratchlistEntryCreateRequestSchema`. Anything longer is rejected with 400 before the row hits SQLite. Test pins the new behavior: a 129-char id returns 400 and never reaches the engine. Co-authored-by: Cursor <cursoragent@cursor.com>
…dismissed (HAPI Bot, PR tiann#896) The previous state machine swallowed the migration banner if the operator reloaded the page before clicking dismiss: the migration flag was set on success, and on remount the init logic mapped a flag-set/dismiss-not-set session to 'pre-migrated', a state the banner explicitly refuses to render. Net effect: a migrated session never prompted for affirmative dismissal. Fixes: - Drop the 'pre-migrated' state. The dismissal flag is now the only signal that suppresses the banner; the migration flag alone means 'banner shows until dismissed' (now or after a reload). - Sessions that had nothing to migrate (no v1 entries in localStorage) pre-emptively write BOTH flags - migrated AND dismissed - so the bot's banner-stickiness fix doesn't surface a banner that has nothing to announce on freshly-created v2 sessions. Tests: - New `reload-before-dismiss leaves the banner visible` test pins the fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2 on the same session reads the localStorage flags and stays 'completed'. - New `opts fresh sessions out of the banner pre-emptively` test pins the no-v1-entries shortcut. - Existing `does not re-migrate on a mount where the migrated flag is already set` updated to assert 'completed' (not the dropped 'pre-migrated'). - Existing `skips migration when localStorage is empty` updated to assert the new 'dismissed' status + the banner-dismissed flag. - Banner test for the 'pre-migrated -> nothing' case removed (the state no longer exists). Co-authored-by: Cursor <cursoragent@cursor.com>
…e does not strand them (closes tiann#920 for v2.0) `mergeSessionData` in `sessionCache.ts` ends every merge codepath with `deleteSession(oldSessionId)`, which fires `ON DELETE CASCADE` on every FK-tied table. `session_scratchlist.session_id` is FK'd with cascade, so without an explicit transfer step every dedup (tiann#448 agent-id collision) and every resume-of-inactive (`syncEngine.resumeSession` -> mergeSessions) silently destroys the operator's per-session notes. This is the gap upstream-discovery agent flagged on tiann#920 against PR tiann#896. With the 2026-06-15 hub-restart cascade incident as evidence (23 sessions auto-archived in a single bounce, 4 confirmed HAPI-id rotations across 2 bounces), unmitigated this would violate v2.0's "survives reloads / second laptop / clear-site-data" promise the first time the operator hits a hub bounce. Fix: - New `transferScratchlistEntries(db, fromSessionId, toSessionId)` in `hub/src/store/scratchlist.ts`. Atomic via BEGIN/COMMIT. Uses `UPDATE OR IGNORE` so rows that would collide on PRIMARY KEY (session_id, entry_id) simply do not move - the dedup target's copy wins, matching the operator's mental model that the consolidated session is authoritative. Cleans up any collision-loser rows so the no-delete codepath (`mergeSessionHistory`) is symmetric with the delete path. - Wired into `mergeSessionData` BEFORE the `deleteSession()` call, alongside the existing message-merge step. Both `mergeSessions` (deleteOld=true) and `mergeSessionHistory` (deleteOld=false) get coverage because both can rotate the visible session id. - Emits `session-updated{scratchlistUpdatedAt}` on the new session so any web client looking at the consolidated id invalidates and refetches; for the keep-old codepath the emit also fires on the old id since it stays alive but is now empty of scratchlist. Tests (`sessionCache-merge-scratchlist.test.ts`, 7 cases): - mergeSessions (deleteOld=true): rows move, old is gone, no stranded rows. - mergeSessions PK collision: dedup target wins, unique-to-old rows still come across. - mergeSessions SSE: exactly one scratchlist patch on the new id. - mergeSessions no-op: zero rows -> zero emits. - mergeSessionHistory (deleteOld=false): rows move, old session stays alive but empty of scratchlist. - mergeSessionHistory SSE: emits on BOTH old and new ids. - Cascade-delete safety smoke: post-merge, an explicit operator delete of the new session DOES cascade-delete its scratchlist (i.e. the FK cascade we want is intact; the bug was triggering it on the wrong id). Web layer note: v1 localStorage is keyed by HAPI session id; on rotation the old key is orphaned but no longer represents data loss because the hub now holds the canonical state and the offline-cache mirror re-populates `hapi.scratchlist.v1.<newId>` on first read of the consolidated session. Documented as a known limitation; not a blocker for v2.0 because the hub is the source of truth. tiann#894 (v2.1 migrate-on-delete) inherits a related concern about operator-Delete vs merge-Delete consent flow - flagged in the upstream-discovery handoff, separate scope. Co-authored-by: Cursor <cursoragent@cursor.com>
…s V10→V11 Upstream landed V9→V10 as sessions.service_tier (tiann#898/tiann#904). Scratchlist v2 moves to V10→V11 so both migrations coexist without clobbering each other. - mergeSessionData conflict resolved: keep upstream migrateFromV9ToV10 (service_tier) and add migrateFromV10ToV11 (session_scratchlist) - SCHEMA_VERSION bumped 10 → 11 - Rename migration-v10.test.ts → migration-v11.test.ts with updated multi-hop coverage (V9→V10→V11) - Add serviceTier: null to scratchlist route test session fixture (required by upstream Session type after tiann#898) Co-authored-by: Cursor <cursoragent@cursor.com>
063a139 to
4c1a8d0
Compare
There was a problem hiding this comment.
Findings
- [Major] All-collision scratchlist transfers leave the still-alive source session cache stale —
transferScratchlistEntries()deletes colliding rows fromoldSessionId, butmergeSessionData()only emits scratchlist invalidations whenmoved > 0. In themergeSessionHistory(..., deleteOldSession=false)path, if every old entry collides with the target PK, the old session remains visible while its scratchlist rows were deleted, and any web client on that old session keeps showing entries that now 404 on edit/delete until some unrelated refetch. Evidence:hub/src/sync/sessionCache.ts:781.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full current PR diff reviewed with prior HAPI Bot review and follow-up context. One sync invalidation gap found in the scratchlist merge path.
Testing
- Not run (automation): static PR review only; did not execute code from PR content. Suggested coverage: add a
mergeSessionHistorytest where old and new sessions contain only the sameentryId, then assert a scratchlist invalidation is emitted for the still-alive old session.
HAPI Bot
…ays alive (HAPI Bot, PR tiann#896) When mergeSessionHistory deletes every old scratchlist row via PK collision (moved=0, collided>0) the still-alive old session kept showing stale cached entries until an unrelated refetch. Emit scratchlistUpdatedAt on the old id whenever collided>0 on the keep-old codepath, not only when moved>0. New-session emit stays gated on moved>0 since the target row is unchanged on full collision. Test pins the all-collision mergeSessionHistory case. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Failed localStorage migrations retry in a tight render loop —
queryKeys.scratchlist(sessionId)returns a new array each render, and the migration effect depends on thatqueryKey. When any POST fails, the effect writes failed rows back, clearsmigrationAttemptedRef, sets status back toidle, and re-renders; becausequeryKeyidentity changed, the effect immediately starts the same failed migration again instead of waiting for a future mount. Persistent 409/offline failures can hammer the hub and keep the UI cyclingmigrating/idle. Evidenceweb/src/lib/use-hub-scratchlist.ts:158.
Suggested fix:import { useCallback, useEffect, useMemo, useRef, useState } from 'react' const queryKey = useMemo(() => queryKeys.scratchlist(sessionId), [sessionId])
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full current PR diff reviewed with prior HAPI Bot review and follow-up compare context. The previous collision invalidation issue is addressed; one web migration retry regression remains.
Testing
- Not run (automation): static PR review only; did not execute code from PR content. Suggested coverage: a migration test where
createScratchlistEntryalways rejects should assert it is not called again until remount/refetch.
HAPI Bot
…p (HAPI Bot, PR tiann#896) useMemo on queryKeys.scratchlist(sessionId) so the migration effect does not re-fire every render after a failed POST clears migrationAttemptedRef. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Optimistic add can duplicate the new scratchlist entry when SSE wins the race — the server emits
scratchlistUpdatedAtbefore the POST returns, so another tab or the same tab can invalidate/refetchqueryKeys.scratchlist(sessionId)and cache the canonical row while the mutation is still pending.onSuccessthen only filters the temporary optimistic id and prependsdata.entry, leaving two rows with the sameentryIdin the UI/offline cache until a later refetch. Evidenceweb/src/lib/use-hub-scratchlist.ts:353.
Suggested fix:queryClient.setQueryData<ScratchlistResponse>(queryKey, (prev) => { if (!prev) return { entries: [data.entry] } const without = prev.entries.filter((e) => e.entryId !== context?.optimisticEntryId && e.entryId !== data.entry.entryId ) return { entries: [data.entry, ...without] } })
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full current PR diff reviewed with prior HAPI Bot review and follow-up compare context. Prior tight-loop retry finding is fixed; one remaining optimistic reconciliation race can leave duplicate scratchlist rows client-side.
Testing
- Not run (automation): static PR review only; did not execute code from PR content. Suggested coverage: seed the scratchlist query with the canonical server row before
createScratchlistEntryresolves, then assertadd()leaves one row for thatentryId.
HAPI Bot
…API Bot, PR tiann#896) onSuccess now drops both the temporary optimistic id and any existing row with the canonical entryId so a fast SSE invalidation cannot leave twins. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Missing-entry errors restore deleted scratchlist rows in stale clients — the new optimistic mutations roll back to
previousDatafor every error. When another tab/device has already deleted an entry, this client can still have the old query cache, send DELETE/PUT, receive the route's 404, and then restore the stale row locally; the offline-cache mirror can persist that stale row until a later refetch. Treat 404 as stale cache instead of rollback. Evidenceweb/src/lib/use-hub-scratchlist.ts:411.
Suggested fix:import { ApiError, type ApiClient } from '@/api/client' const isNotFound = (error: unknown) => error instanceof ApiError && error.status === 404 onError: (error, variables, context) => { if (isNotFound(error)) { queryClient.setQueryData<ScratchlistResponse>(queryKey, (prev) => { if (!prev) return prev return { entries: prev.entries.filter((e) => e.entryId !== variables.entryId) } }) void queryClient.invalidateQueries({ queryKey }) return } if (context?.previousData !== undefined) { queryClient.setQueryData(queryKey, context.previousData) } }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full current PR diff reviewed with prior HAPI Bot review and follow-up compare context. Prior duplicate-row finding is fixed; one remaining stale-cache race can make deleted scratchlist entries reappear client-side after a 404.
Testing
- Not run (automation): static PR review only. Suggested coverage: seed the scratchlist query with an entry, make
deleteScratchlistEntryreject withnew ApiError(..., 404), and assert the entry stays removed and the query is invalidated.
HAPI Bot
…back (HAPI Bot, PR tiann#896) When another client already removed an entry, keep it gone locally and invalidate instead of restoring previousData from optimistic rollback. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full current PR diff reviewed with prior HAPI Bot review and follow-up compare context. The previously reported stale 404 rollback issue is addressed in the latest head. No new Blocker/Major/Minor/Nit findings above the confidence bar.
- Residual risk: review was static only; I did not execute PR code.
Testing
- Not run (automation): static PR review only.
HAPI Bot
Closes #893. Tracking issue for v2.1 (delete with summarize-and-migrate): #894.
Summary
Promotes per-session scratchlist persistence from per-device localStorage to a hub-backed typed table so notes follow the operator across devices. The v1 panel UI / styling / FUE / keyboard shortcut / copy-to-clipboard surface are deliberately unchanged - this is a backend + sync-layer feature.
Hub side
session_scratchlisttyped table with(session_id, entry_id)PK,text,created_at,updated_at, FK onsessions(id) ON DELETE CASCADE, and(session_id, created_at DESC)index. Schema bumped V9 → V10; idempotent migration added to both the legacy backfill and step ladders./api/sessions/:id/scratchlist[/:entryId], all routed through the existingrequireSessionFromParamguard so namespace / ownership enforcement is identical to other session-scoped routes.entryIdreported idempotently (200) so the migration retry path is safe even after a partial failure.SessionPatchSchemaextended withscratchlistUpdatedAt?: number; every successful mutation emits asession-updatedSSE patch carrying the token. (Per the operator's piggyback decision in the brief - aligns with the parallel Performance: web client useSession refetch storm dominates hub access logs (~95% of syslog volume on busy installs) #884 patch-shape extension; either order merges cleanly.)Web side
queryKeys.scratchlist(sessionId)); localStorage demoted to offline cache. Add / delete / update mutations are optimistic with rollback on error.CursorMigrationBanner) tells the operator their notes are now in the hub. Banner dismissal is per-session and persistent.scratchlistinvalidation when the patch carriesscratchlistUpdatedAt, so cross-device + cross-tab updates land within an SSE round-trip.Out of scope
The "delete with summarize-and-migrate" UX flow (separate tracking issue #894) is not implemented here. v2.0 does the simpler
"This will also delete N scratchlist entries"confirm-copy variant only.Test plan
ScratchlistStoreCRUD + ordering + cross-session isolation.SyncEnginemutations emit onesession-updatedpatch per change withscratchlistUpdatedAt.migrationStatus === 'completed', dismissal persists.inert/ persistence / promote / shortcut / regression) - all pass unchanged, confirming no UI regression.bun typecheckclean across cli / web / hub.bun run testclean for hub (456) and web (982); cli (964 passing) - one pre-existingrunner.integration.test.tsfailure onupstream/mainis unrelated.Made with Cursor