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 6 commits into
Conversation
…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>
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
| entryId: entry.id, | ||
| createdAt: entry.createdAt | ||
| }) | ||
| } catch { |
There was a problem hiding this comment.
[MAJOR] The migration loop treats every per-entry POST failure as non-fatal and still writes the migrated flag. Because the later offline-cache effect mirrors the initial empty hub response into hapi.scratchlist.v1.<sessionId>, a transient failure or cap rejection can leave entries neither uploaded nor preserved locally. Keep failed entries in localStorage and do not set the migration flag until all intended entries are accepted.
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)| // (e.g. direct REST call) hits this guard. Bumped only with the | ||
| // shared SCRATCHLIST_MAX_ENTRIES constant. | ||
| const currentCount = engine.countScratchlistEntries(sessionResult.sessionId) | ||
| if (currentCount >= SCRATCHLIST_MAX_ENTRIES) { |
There was a problem hiding this comment.
[MAJOR] This cap check runs before duplicate detection, so a retry for an entry that already exists returns 409 once the session has 200 rows. That breaks the route contract that duplicate entryId POSTs are idempotent for migration retries. Check an existing caller-supplied entryId before enforcing the new-entry cap.
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)
}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
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>
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
| * tree. New entries created post-v2 can omit this and let the hub | ||
| * generate one. | ||
| */ | ||
| entryId: z.string().min(1).optional(), |
There was a problem hiding this comment.
[MINOR] entryId only has .min(1), but POST persists this client-supplied value as part of the session_scratchlist primary key. The new entry-count/text caps do not bound ID size, so an authenticated/direct client can store oversized keys and bloat the SQLite table/index beyond the intended scratchlist limits. Bound it near UUID/fallback ID sizes.
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()
})…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
| } | ||
| if (readBannerDismissed(sessionId)) { | ||
| setMigrationStatus('dismissed') | ||
| } else if (readMigrationFlag(sessionId)) { |
There was a problem hiding this comment.
[MINOR] After a successful migration writes hapi.scratchlist.v2.migrated.*, a reload before the operator clicks the banner dismiss button enters this pre-migrated branch. Since ScratchlistMigrationBanner renders only for completed, the banner is then suppressed permanently without the persistent dismissal flag being written. Keep migrated-but-not-dismissed sessions in the banner-visible state, and explicitly opt out only when there were no local entries to migrate.
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
}…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
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