Skip to content

WH-097: Review and improve frontend liveness with real-time updates#99

Merged
edmofro merged 13 commits intomainfrom
workhorse/wh-097-spec
Apr 15, 2026
Merged

WH-097: Review and improve frontend liveness with real-time updates#99
edmofro merged 13 commits intomainfrom
workhorse/wh-097-spec

Conversation

@edmofro
Copy link
Copy Markdown
Member

@edmofro edmofro commented Apr 14, 2026

Summary

Liveness of interface improvements:

  • status indicator on recent conversations area doesn't change when status does
  • create pr button disabled state doesn't change when there are changes available
  • mockup viewer shows old version for about 5 seconds after opening it after changes being made

These are just a few that I've noticed. In general I'd really like the front end to be as live as possible, using sockets or whatever and db trigger listen/notify tooling to ensure that happens. Do a full review and propose changes

Journey

  • Explored codebase to understand current real-time infrastructure (Postgres LISTEN/NOTIFY → SSE → React Query) and identified specific problem areas in PR section, mockup data fetching, and card workspace.
  • User requested fixes to all real-time trigger scenarios except spec explorer. Assistant identified need to handle card updates (title/description propagation across users) and new card creation events, beginning spec revisions.
  • User provided architectural guidance on generic database triggers. Assistant updated spec to use a single reusable trigger function (table name + row ID + changed columns) with frontend logic handling what matters, eliminating implementation details from the database layer.
  • Fresh-eyes review identified critical cross-spec conflict with agent-streaming-status.md around notification architecture. Review uncovered potential inconsistencies in trigger strategy and event handling between frontend-liveness and related specs.
  • Updated frontend-liveness.md and cross-referenced specs to resolve notification architecture conflicts. Removed unnecessary DELETE trigger scope, added routing criteria for INSERT events and related-table notifications, clarified initial state sourcing.
  • User initiated implementation of acceptance criteria from specs. Assistant performed comprehensive codebase analysis to understand existing real-time infrastructure, migration structure, component rendering, and user membership determination before beginning implementation work.
  • Identified and fixed fileWrites effect re-fetching all entries on each new entry. Implemented ref tracking for last processed index to only process new entries. Verified build compilation and import correctness.
  • Verified React Query v5 compatibility with getQueriesData filter syntax and confirmed CardDetailLayout properly re-renders on cache updates from SSE hook.

Specs

  • .workhorse/specs/agent-streaming-status.md
  • .workhorse/specs/conversation-sessions.md
  • .workhorse/specs/frontend-liveness.md

Changed files

  • prisma/migrations/0002_generic_change_notify_trigger/migration.sql
  • src/app/api/sidebar-events/route.ts
  • src/components/card/CardWorkspace.tsx
  • src/lib/dbNotifications.ts
  • src/lib/hooks/useSidebarEvents.ts

Test plan

Review Hero

  • Run Review Hero
  • Auto-fix review suggestions
  • Auto-fix CI failures

@review-hero
Copy link
Copy Markdown

review-hero bot commented Apr 14, 2026

🦸 Review Hero (could not post inline comments — showing here instead)

src/lib/hooks/useSidebarEvents.ts:114

[Bugs & Correctness] critical

card_created events are appended to ALL cached board queries, not just the board for the card's project. getQueriesData({ queryKey: ['project-board'] }) matches every ['project-board', slug] key in the cache. A new card on Project A will appear on Project B's board too. Fix: check whether the card's team belongs to the board's project before appending, e.g. if (!data.project.teams.some(t => t.id === card.teamId)) continue.


src/components/card/CardWorkspace.tsx:454

[Bugs & Correctness] suggestion

The null guard card.prUrl != null prevents syncing a server-side null back to local state. If a PR is unlinked (prUrl set to null via an SSE card_updated event updating the react-query cache), the CardWorkspace will keep showing the stale PR URL/number because the effect skips null values. Remove the guard or change to always sync: setLocalPrUrl(card.prUrl ?? null). Same for card.prNumber on line 456.


src/app/api/sidebar-events/route.ts:35

[Bugs & Correctness] suggestion

The teamToProject map is built once at SSE connection time and never refreshed. Since SSE connections are long-lived (kept open indefinitely with keepalives), any team or project created after the connection opens will be invisible to that client's card/comment/activity routing — events for cards on new teams are silently dropped by the if (!projectId || !allProjectIds.has(projectId)) return check. Consider periodically rebuilding the map, or invalidating it when a project/team change notification arrives.


src/lib/hooks/useSidebarEvents.ts:37

[Design & Architecture] critical

This hook has grown from a focused sidebar-events handler (~60 lines) into a 280-line god-hook that manages cache updates for 5 event types across 4+ query caches (sidebar-data, project-board, card-detail, branch-status, jockey). The name useSidebarEvents no longer reflects its scope, and the single-function-does-everything structure makes it hard to test, reason about, or extend. Split into a generic useRealtimeEvents dispatcher that delegates to per-domain updaters (e.g. updateSessionCache, updateCardCache, updateCommentCache) — either as separate hooks composed together or as extracted functions. This also eliminates the inline type assertions for card/comment/activity payloads, which should be shared types imported from queries.ts or a dedicated events types file.


src/lib/dbNotifications.ts:18

[Design & Architecture] suggestion

The refactor replaced per-userId listener sets with a single global broadcast set. Previously, a session notification for user A would only invoke user A's listener. Now every notification invokes every connected client's callback, and each callback fires its own DB query (findUnique for the changed record). With N connected SSE clients, a single DB write now triggers N database queries instead of 1. For sessions this is mitigated by the userId check, but for cards/comments/activities there's no early filtering — every client queries and processes every change. Consider restoring a lightweight routing layer (e.g. keying listeners by table name, or by project ID) so notifications only reach interested subscribers.


src/app/api/sidebar-events/route.ts:86

[Design & Architecture] suggestion

The 150ms debounce is applied uniformly to every table. This makes sense for ConversationSession — a single agent turn fires many field-level UPDATE triggers in quick succession. But CardComment and CardActivity are insert-only events that fire exactly once per record, so debouncing them just adds 150ms of latency for no coalescing benefit. Consider skipping the debounce for insert operations or for tables that don't produce bursts (route directly to dispatchChange).


src/lib/dbNotifications.ts:40

[Frontend Snappiness] critical

Broadcasting every notification to every listener causes O(N) DB queries per change event instead of O(1). Previously subscribeSessionChanges(userId, cb) filtered at the fan-out layer — only the relevant user's callback fired. Now subscribeTableChanges delivers every notification to every connected SSE client. Each client independently calls findUnique on the same record: for session changes, N-1 out of N queries are discarded (userId !== userId); for card/comment/activity changes, N clients execute identical queries returning the same row. With 20 connected users and a burst of card edits, this amplifies to 20× the DB load for no benefit. Fix: either restore per-key fan-out in dbNotifications.ts (e.g. keyed by table:id or table:userId), or fetch the record once in a shared handler and broadcast the hydrated payload to all listeners, rather than having each listener query independently.


src/app/api/sidebar-events/route.ts:288

[Frontend Snappiness] suggestion

dispatchChange fires the async handler with void (fire-and-forget) with no concurrency limit. Under a burst of changes to different records (e.g. a bulk status update touching 50 cards), all 50 handleCardChange calls run concurrently, each issuing a Prisma query. Multiplied by N connected clients, this can spike to N×50 concurrent DB queries, risking connection pool exhaustion. Consider a simple concurrency limiter or serial queue per table type to cap in-flight queries.


src/app/api/sidebar-events/route.ts:35

[Security] critical

Authorization bypass: card, comment, and activity events are broadcast to all authenticated users without checking GitHub repo access. The existing /api/sidebar-data endpoint filters projects via filterAccessibleRepos(), but this SSE endpoint fetches all projects (prisma.project.findMany) and builds allProjectIds containing every project. The allProjectIds.has(projectId) check in handleCardChange, handleCommentChange, and handleActivityChange is a tautology — it always passes. Any authenticated user receives real-time card titles, descriptions, comments, and activity for every project, including repos they have no GitHub access to. Fix: call filterAccessibleRepos(user.accessToken, ...) to build allProjectIds from only the projects the user can access, mirroring what /api/sidebar-data does.


src/lib/dbNotifications.ts:37

[Security] suggestion

The global listener fan-out means every Postgres notification triggers callbacks for ALL connected SSE clients. Each callback performs a full Prisma query (card, comment, or activity lookup) before checking access. With N connected users and M changes/sec, this is N×M database queries — most of which will be discarded. Previously, listenersByUser ensured only the relevant user's callbacks fired. Consider re-introducing a lightweight pre-filter (e.g. by table or project) in the fan-out layer, or at minimum moving the DB query after an access check using data from the notification payload, to avoid amplified database load that could be leveraged for denial of service.

@review-hero
Copy link
Copy Markdown

review-hero bot commented Apr 14, 2026

🦸 Review Hero Summary
12 agents reviewed this PR | 4 critical | 6 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 5 below threshold, 1 suppressed

Below consensus threshold (5 unique issues not confirmed by majority)
Location Agent Severity Comment
src/app/api/sidebar-events/route.ts:86 Security suggestion The emit() helper serialises and sends data to the SSE stream inside a try/catch that silently swallows all errors. If JSON.stringify fails on unexpected object shapes (e.g. circular references...
src/app/api/sidebar-events/route.ts:321 Bugs & Correctness critical The debounce timer captures payload.op from the closure of whichever notification last reset the timer. If a card insert is immediately followed by an update within the 150ms window (common w...
src/components/card/CardWorkspace.tsx:354 Bugs & Correctness suggestion fileWriteCursorRef tracks position in the fileWrites array but doesn't account for the array being replaced entirely (e.g. after a React Query refetch that returns a fresh array). If `fileWrite...
src/lib/dbNotifications.ts:34 Security suggestion No runtime validation of NOTIFY payload shape: JSON.parse(msg.payload) as TableChangePayload is a type assertion, not a runtime check. If the DB trigger emits a payload missing table, id, or ...
src/lib/hooks/useSidebarEvents.ts:230 Frontend Snappiness suggestion Comment and activity events are appended to data.card.comments and data.card.activities arrays in the React Query cache without any upper bound. For a long-lived card with high comment/activity...
Local fix prompt (copy to your coding agent)

Fix these issues identified on the pull request. One commit per issue fixed.


src/lib/hooks/useSidebarEvents.ts:114: card_created events are appended to ALL cached board queries, not just the board for the card's project. getQueriesData({ queryKey: ['project-board'] }) matches every ['project-board', slug] key in the cache. A new card on Project A will appear on Project B's board too. Fix: check whether the card's team belongs to the board's project before appending, e.g. if (!data.project.teams.some(t => t.id === card.teamId)) continue.


src/components/card/CardWorkspace.tsx:454: The null guard card.prUrl != null prevents syncing a server-side null back to local state. If a PR is unlinked (prUrl set to null via an SSE card_updated event updating the react-query cache), the CardWorkspace will keep showing the stale PR URL/number because the effect skips null values. Remove the guard or change to always sync: setLocalPrUrl(card.prUrl ?? null). Same for card.prNumber on line 456.


src/app/api/sidebar-events/route.ts:35: The teamToProject map is built once at SSE connection time and never refreshed. Since SSE connections are long-lived (kept open indefinitely with keepalives), any team or project created after the connection opens will be invisible to that client's card/comment/activity routing — events for cards on new teams are silently dropped by the if (!projectId || !allProjectIds.has(projectId)) return check. Consider periodically rebuilding the map, or invalidating it when a project/team change notification arrives.


src/lib/hooks/useSidebarEvents.ts:37: This hook has grown from a focused sidebar-events handler (~60 lines) into a 280-line god-hook that manages cache updates for 5 event types across 4+ query caches (sidebar-data, project-board, card-detail, branch-status, jockey). The name useSidebarEvents no longer reflects its scope, and the single-function-does-everything structure makes it hard to test, reason about, or extend. Split into a generic useRealtimeEvents dispatcher that delegates to per-domain updaters (e.g. updateSessionCache, updateCardCache, updateCommentCache) — either as separate hooks composed together or as extracted functions. This also eliminates the inline type assertions for card/comment/activity payloads, which should be shared types imported from queries.ts or a dedicated events types file.


src/lib/dbNotifications.ts:18: The refactor replaced per-userId listener sets with a single global broadcast set. Previously, a session notification for user A would only invoke user A's listener. Now every notification invokes every connected client's callback, and each callback fires its own DB query (findUnique for the changed record). With N connected SSE clients, a single DB write now triggers N database queries instead of 1. For sessions this is mitigated by the userId check, but for cards/comments/activities there's no early filtering — every client queries and processes every change. Consider restoring a lightweight routing layer (e.g. keying listeners by table name, or by project ID) so notifications only reach interested subscribers.


src/app/api/sidebar-events/route.ts:86: The 150ms debounce is applied uniformly to every table. This makes sense for ConversationSession — a single agent turn fires many field-level UPDATE triggers in quick succession. But CardComment and CardActivity are insert-only events that fire exactly once per record, so debouncing them just adds 150ms of latency for no coalescing benefit. Consider skipping the debounce for insert operations or for tables that don't produce bursts (route directly to dispatchChange).


src/lib/dbNotifications.ts:40: Broadcasting every notification to every listener causes O(N) DB queries per change event instead of O(1). Previously subscribeSessionChanges(userId, cb) filtered at the fan-out layer — only the relevant user's callback fired. Now subscribeTableChanges delivers every notification to every connected SSE client. Each client independently calls findUnique on the same record: for session changes, N-1 out of N queries are discarded (userId !== userId); for card/comment/activity changes, N clients execute identical queries returning the same row. With 20 connected users and a burst of card edits, this amplifies to 20× the DB load for no benefit. Fix: either restore per-key fan-out in dbNotifications.ts (e.g. keyed by table:id or table:userId), or fetch the record once in a shared handler and broadcast the hydrated payload to all listeners, rather than having each listener query independently.


src/app/api/sidebar-events/route.ts:288: dispatchChange fires the async handler with void (fire-and-forget) with no concurrency limit. Under a burst of changes to different records (e.g. a bulk status update touching 50 cards), all 50 handleCardChange calls run concurrently, each issuing a Prisma query. Multiplied by N connected clients, this can spike to N×50 concurrent DB queries, risking connection pool exhaustion. Consider a simple concurrency limiter or serial queue per table type to cap in-flight queries.


src/app/api/sidebar-events/route.ts:35: Authorization bypass: card, comment, and activity events are broadcast to all authenticated users without checking GitHub repo access. The existing /api/sidebar-data endpoint filters projects via filterAccessibleRepos(), but this SSE endpoint fetches all projects (prisma.project.findMany) and builds allProjectIds containing every project. The allProjectIds.has(projectId) check in handleCardChange, handleCommentChange, and handleActivityChange is a tautology — it always passes. Any authenticated user receives real-time card titles, descriptions, comments, and activity for every project, including repos they have no GitHub access to. Fix: call filterAccessibleRepos(user.accessToken, ...) to build allProjectIds from only the projects the user can access, mirroring what /api/sidebar-data does.


src/lib/dbNotifications.ts:37: The global listener fan-out means every Postgres notification triggers callbacks for ALL connected SSE clients. Each callback performs a full Prisma query (card, comment, or activity lookup) before checking access. With N connected users and M changes/sec, this is N×M database queries — most of which will be discarded. Previously, listenersByUser ensured only the relevant user's callbacks fired. Consider re-introducing a lightweight pre-filter (e.g. by table or project) in the fan-out layer, or at minimum moving the DB query after an access check using data from the notification payload, to avoid amplified database load that could be leveraged for denial of service.

Comment thread src/lib/hooks/useRealtimeEvents.ts
Comment thread src/lib/realtimeEvents.ts
Comment thread src/lib/realtimeEvents.ts Outdated
Comment thread src/app/api/sidebar-events/route.ts Outdated
Comment thread src/lib/realtimeEvents.ts
@review-hero
Copy link
Copy Markdown

review-hero bot commented Apr 15, 2026

🦸 Review Hero Summary
9 agents reviewed this PR | 3 critical | 2 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 6 below threshold

Below consensus threshold (6 unique issues not confirmed by majority)
Location Agent Severity Comment
src/app/api/sidebar-events/route.ts:36 Security suggestion user.accessToken (a GitHub OAuth token) is read from the DB and held in the closure for the lifetime of the SSE connection. If the token is later rotated or revoked in the database (e.g. user re-...
src/app/api/sidebar-events/route.ts:37 Frontend Snappiness suggestion getProjects() and filterAccessibleRepos() are awaited at connection time for every SSE client, but they fetch the full project list and make GitHub API calls to check access. With many concurre...
src/components/card/CardWorkspace.tsx:351 Bugs & Correctness suggestion fileWriteCursorRef is never reset when card.id changes. The fileWrites array comes from useAgentSession(card.id, ...) and resets to [] when a new stream starts, but the cursor ref retains...
src/lib/dbNotifications.ts:34 Security suggestion The channel was narrowed from per-user fan-out (listenersByUser) to a single global listener set. The NOTIFY payload is parsed with JSON.parse and cast directly to TableChangePayload with no ...
src/lib/hooks/useSidebarEvents.ts:102 Bugs & Correctness suggestion The card_created handler adds the new card to every cached board query without checking team ownership: it iterates all ['project-board'] queries and appends unconditionally. The parallel imple...
src/lib/realtimeEvents.ts:133 Frontend Snappiness suggestion The runWithLimit helper duplicates its .finally() cleanup in both the immediate-execution and the queued-execution branches. More importantly, errors in the queued callback's fn() are silentl...
Local fix prompt (copy to your coding agent)

Fix these issues identified on the pull request. One commit per issue fixed.


src/lib/hooks/useRealtimeEvents.ts:307: EventSource leak on reconnection. When onerror fires, it closes the current EventSource and schedules setTimeout(connect, delay). The reconnected EventSource returned by connect() is never captured — setTimeout discards the return value. The cleanup in useEffect (line 316-319) only closes the original EventSource reference from the first connect() call. If the connection errors and reconnects, then the component unmounts, the reconnected EventSource stays open as a zombie: it continues receiving SSE events and mutating the React Query cache after unmount. Fix: store the EventSource in a ref (eventSourceRef.current = eventSource inside connect) and close the ref in the cleanup function.


src/lib/realtimeEvents.ts:365: ensureRunning() subscribes to subscribeTableChanges but never unsubscribes, even when all hydrated listeners disconnect. Once any SSE client connects, the raw DB listener (and its dedicated Postgres connection) stays alive for the lifetime of the process. The old per-user listener model cleaned up naturally when the last user disconnected. Consider adding a symmetric teardown: when hydratedListeners becomes empty after a delete, call unsubscribeRaw() and reset it to null. This would let the idle DB connection close and pending debounce timers be cleared.


src/lib/realtimeEvents.ts:139: The concurrency-limited queue (queue array) has no maximum size. Under a notification storm (bulk import, migration, rapid agent activity), entries accumulate faster than the 10 concurrent queries can drain them — unbounded memory growth and increasingly stale hydration queries that hit the DB for data that has already changed again. Add a queue size cap (e.g. 200) and drop-or-coalesce excess entries, or switch to a bounded ring buffer.


src/app/api/sidebar-events/route.ts:48: Authorization bypass via stale access control on long-lived SSE connections. accessibleTeamIds is computed once at connection time (lines 37-53) and never refreshed for the lifetime of the SSE stream, which can persist for hours or days. If a user's GitHub repo access is revoked after they connect, they continue receiving card, comment, and activity events for repos they no longer have access to. The old code only streamed session events filtered by immutable userId, so stale access wasn't a concern. The new code adds card/comment/activity events filtered by mutable GitHub repo permissions, opening an authorization bypass window. Fix: periodically re-evaluate accessibleTeamIds (e.g. every 5 minutes, matching the filterAccessibleRepos cache TTL), or disconnect and force reconnect when the cached permissions expire.


src/lib/realtimeEvents.ts:107: Defence-in-depth regression: the old per-user subscription model (subscribeSessionChanges(userId, cb)) meant each SSE handler only ever received notifications for its own user. The new broadcast model sends ALL hydrated events — including other users' session data (userId, card details, message counts) — to every registered callback. Access filtering depends entirely on shouldEmit in each SSE handler. A single bug in that function (e.g. a missing case in the switch, or a new event kind added without a filter) would leak all events to all users. Consider either: (a) passing a filter predicate into subscribeHydratedEvents so the broadcast layer can enforce it, or (b) partitioning listeners by a key (userId or teamId set) so events are only delivered to relevant handlers.

Comment thread src/lib/realtimeEvents.ts
Comment thread src/lib/hooks/useRealtimeEvents.ts
Comment thread src/lib/realtimeEvents.ts
Comment thread src/app/api/sidebar-events/route.ts
Comment thread src/app/api/sidebar-events/route.ts
Comment thread src/app/api/sidebar-events/route.ts Outdated
Comment thread src/lib/realtimeEvents.ts Outdated
Comment thread src/app/api/sidebar-events/route.ts
Comment thread src/lib/realtimeEvents.ts
@review-hero
Copy link
Copy Markdown

review-hero bot commented Apr 15, 2026

🦸 Review Hero Summary
12 agents reviewed this PR | 1 critical | 8 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 26 below threshold

Below consensus threshold (26 unique issues not confirmed by majority)
Location Agent Severity Comment
src/app/api/sidebar-events/route.ts:46 Bugs & Correctness suggestion refreshAccessibleTeams captures user.accessToken from the request-time closure. For long-lived SSE connections, the token may expire. Subsequent periodic refresh calls (triggered from the keepa...
src/app/api/sidebar-events/route.ts:46 Security suggestion The user.accessToken (GitHub OAuth token) is captured once at connection time and reused for the lifetime of the SSE connection. If the token is rotated or revoked in the database, the SSE endpoi...
src/app/api/sidebar-events/route.ts:47 Frontend Snappiness suggestion refreshAccessibleTeams() calls getProjects() (DB query) then filterAccessibleRepos() (up to N GitHub API calls) independently per SSE connection. If a user has multiple tabs open, each connec...
src/app/api/sidebar-events/route.ts:60 Frontend Snappiness suggestion Waterfall queries during SSE connection setup. refreshAccessibleTeams() (DB + GitHub API) and the findMany for active sessions (line 64) are independent but awaited sequentially. This delays th...
src/app/api/sidebar-events/route.ts:60 Frontend Snappiness suggestion If refreshAccessibleTeams() throws on initial connect (GitHub API down, token expired), the error propagates and the SSE response is never returned — no real-time updates at all, including user-s...
src/app/api/sidebar-events/route.ts:61 Security critical Silent failure on access refresh leaves stale authorisation in place. When refreshAccessibleTeams() fails (token expired/revoked, GitHub API down), .catch(() => {}) swallows the error and the o...
src/app/api/sidebar-events/route.ts:101 Frontend Snappiness suggestion refreshAccessibleTeams().catch(() => {}) is fire-and-forget inside the keepalive interval. If the GitHub API call takes longer than the keepalive period, the next keepalive tick will see `Date.no...
src/app/api/sidebar-events/route.ts:105 Security suggestion When refreshAccessibleTeams() fails (expired OAuth token, GitHub API error, DB error), the error is silently swallowed by .catch(() => {}) and the previous accessibleTeamIds set persists inde...
src/app/api/sidebar-events/route.ts:108 Design & Architecture suggestion The periodic access-control refresh is piggy-backed onto the keepalive interval — refreshAccessibleTeams() is called inside the keepalive's try block. This conflates two unrelated responsibilitie...
src/app/api/sidebar-events/route.ts:125 Bugs & Correctness critical toSSE for session events doesn't distinguish INSERT from UPDATE — unlike the card case (line 132) which correctly checks event.op. The DB trigger sends ALL columns in changed for INSERTs, s...
src/lib/dbNotifications.ts:38 Security suggestion Defence-in-depth regression: the old subscribeSessionChanges scoped notifications by userId at the LISTEN/NOTIFY layer, so session data for user A never reached user B's callback. The new `subs...
src/lib/hooks/useRealtimeEvents.ts:1 Design & Architecture suggestion This 324-line file bundles four independent concerns: SSE type definitions (lines 9–56), four domain-specific cache updaters (session/card/comment/activity, each touching unrelated query keys), and...
src/lib/hooks/useRealtimeEvents.ts:1 Design & Architecture suggestion This 324-line file mixes three distinct concerns: SSE event type definitions, connection lifecycle management (retry, reconnect, EventSource wiring), and domain-specific React Query cache updaters ...
src/lib/hooks/useRealtimeEvents.ts:67 Frontend Snappiness nitpick The old useSidebarEvents agent_start handler had if (idx === 0) return old to bail early when the session was already at the top of the list, avoiding an unnecessary array copy and React re-r...
src/lib/hooks/useRealtimeEvents.ts:91 Bugs & Correctness suggestion If an agent_stop event arrives for a session not in recentSessions, existing (line 87) is undefined. { ...undefined, ...session } silently creates a new entry prepended to the list — the ...
src/lib/hooks/useRealtimeEvents.ts:99 Bugs & Correctness suggestion The session_updated handler (line 101) only maps over existing sessions — it never adds new ones. So if a session INSERT is correctly classified as session_updated (e.g. after fixing the `toSSE...
src/lib/hooks/useRealtimeEvents.ts:128 Design & Architecture suggestion The board-card field mapping (id, identifier, title, description, status, priority, tags, assignee, team) is spelled out identically in both the card_created branch (line 140) and the `card_upd...
src/lib/hooks/useRealtimeEvents.ts:135 Bugs & Correctness critical When a card's teamId changes across projects (the UI has a team dropdown that allows this), the filter data.project.teams.some((t) => t.id === card.teamId) uses the new teamId. The old board ...
src/lib/hooks/useRealtimeEvents.ts:139 Frontend Snappiness suggestion updateCardCache calls qc.getQueriesData({ queryKey: ['project-board'] }) and then qc.getQueriesData({ queryKey: ['card-detail'] }), scanning all matching cached queries. updateCommentCache ...
src/lib/hooks/useRealtimeEvents.ts:309 Bugs & Correctness suggestion The retry setTimeout scheduled in onerror (line 309) is never cancelled during effect cleanup (lines 316-320). If the component unmounts while a retry is pending and remounts quickly (e.g. rout...
src/lib/realtimeEvents.ts:111 Security critical The filter parameter on subscribeHydratedEvents is optional, and broadcast() (line 125) treats a missing filter as 'pass everything' (if (sub.filter && !sub.filter(event)) continue). This m...
src/lib/realtimeEvents.ts:262 Bugs & Correctness suggestion The hydrateComment query does not select the attachments relation, but the client-side updateCommentCache (useRealtimeEvents.ts line 195) hardcodes attachments: [] to satisfy the `CardDetai...
src/lib/realtimeEvents.ts:293 Design & Architecture suggestion The hydration layer eagerly fetches from the DB for every NOTIFY on every watched table, regardless of whether any connected subscriber would pass its filter for that record. For example, a card up...
src/lib/realtimeEvents.ts:318 Bugs & Correctness nitpick The pendingOp map and the comment "Keep the first op in the window — INSERT takes priority over UPDATE" are misleading. INSERT events are dispatched immediately at line 303 and never enter this d...
src/lib/realtimeEvents.ts:321 Design & Architecture nitpick The comment 'Keep the first op in the window — INSERT takes priority over UPDATE' is misleading. INSERTs are dispatched immediately on line 307 and never reach the debounce section, so pendingOp ...
src/lib/realtimeEvents.ts:355 Design & Architecture suggestion pendingOp map is dead code — inserts always return early on line 343, so only updates ever enter the debounce path. The map will only ever store 'update', and the fallback ?? payload.op also ...
Local fix prompt (copy to your coding agent)

Fix these issues identified on the pull request. One commit per issue fixed.


src/lib/realtimeEvents.ts:340: The pendingOp map is dead weight. INSERT events are dispatched immediately on line 330 and never enter the debounce path, so pendingOp will only ever store 'update'. The comment "INSERT takes priority over UPDATE" describes a scenario that can't happen. Remove pendingOp entirely and just pass 'update' directly in the setTimeout callback — it removes a misleading abstraction and three lines of bookkeeping (pendingOp.set, pendingOp.get, pendingOp.delete).


src/lib/hooks/useRealtimeEvents.ts:1: This 324-line file mixes two concerns: SSE connection lifecycle (connect, retry, cleanup) and per-domain React Query cache manipulation (sessions, cards, comments, activities). The four update*Cache functions are pure (QueryClient, Event) → void and have no dependency on SSE or React hooks. Moving them next to the query definitions in queries.ts (or a sibling file) would keep the hook focused on SSE plumbing and make the cache logic independently testable and reusable (e.g. if you add WebSocket transport later).


src/lib/realtimeEvents.ts:130: The old design partitioned listeners by userId in dbNotifications, so a session change only reached that user's SSE handler. The new broadcast model hydrates every session change and iterates all subscribers, with N−1 of them rejecting it via the filter predicate. For cards/comments/activities (team-scoped) this is fine, but for sessions (user-scoped) it's a regression: hydration work is shared (good), but the per-subscriber filter loop is O(connected_users) for every session write. Consider a lightweight partitioning step — e.g. indexing subscriptions by userId for session events — so the broadcast doesn't degrade linearly as concurrent users grow.


src/app/api/sidebar-events/route.ts:55: refreshAccessibleTeams runs independently per SSE connection — every 5 minutes, each connection calls getProjects() (DB query returning all projects) and filterAccessibleRepos() (up to one GitHub API call per repo on cache miss). A user with 3 tabs open triples these calls. The project list is identical across all users and could be shared via a short-TTL in-memory cache. The per-user GitHub access result is already cached inside filterAccessibleRepos, but the getProjects call is not.


src/app/api/sidebar-events/route.ts:101: The refreshAccessibleTeams() call inside the keepalive timer is fire-and-forget with no concurrency guard. lastAccessRefresh is only updated after the async function completes, so if the GitHub API is slow (>30s keepalive interval), the next keepalive tick will see the timestamp as stale and launch another concurrent refresh. Under sustained API latency this stacks up unbounded parallel calls. Add a simple let isRefreshing = false guard, or set lastAccessRefresh = Date.now() optimistically before the await.


src/app/api/sidebar-events/route.ts:44: The user.accessToken is captured once when the SSE connection opens and reused for the entire connection lifetime (potentially hours/days). If the token is rotated via OAuth refresh or the user re-authenticates, the SSE connection keeps using the stale token for filterAccessibleRepos calls. Consider re-fetching the user record (or at least the current access token) from the database during each refreshAccessibleTeams() call to pick up token rotations. This also addresses the edge case where a user's account is deactivated but their SSE connection lives on.


src/lib/realtimeEvents.ts:125: The broadcast() function iterates all subscriptions for every hydrated event, meaning every connected user's filter callback sees every record's full hydrated data (including other users' session titles, card descriptions, comment content, etc.) in-process. While the filter prevents delivery and this is in-process memory (not over the wire), a vulnerability in any filter callback (e.g., an exception that gets caught by the outer try/catch before the continue) would cause the event to be skipped rather than leaked — so this is currently safe. However, consider whether the session userId check could be pushed into hydrateSession to avoid broadcasting other users' session data to all subscriber filter functions as a defence-in-depth measure.


src/app/api/sidebar-events/route.ts:58: Silent failure of refreshAccessibleTeams() in the keepalive handler means stale permissions persist indefinitely. If the user's GitHub token expires or access is revoked, the .catch(() => {}) swallows the error and accessibleTeamIds retains the old set — the user continues receiving real-time card, comment, and activity events for teams they no longer have access to, for the entire lifetime of the SSE connection. Consider: (1) clearing accessibleTeamIds on refresh failure so it fails closed, or (2) closing the SSE connection on repeated refresh failures to force re-authentication.


src/lib/realtimeEvents.ts:109: The filter parameter on subscribeHydratedEvents is optional. The old API (subscribeSessionChanges) required a userId, making it impossible to accidentally subscribe without authorization scoping. With the new API, a future caller that omits the filter receives all hydrated events for all users — sessions, cards, comments, and activities across every team. Make filter a required parameter to enforce authorization at the API boundary.

Comment thread src/lib/realtimeEvents.ts
}
}

export interface CardEvent {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design & Architecture] suggestion

The card payload shape is independently defined in three places: CardEvent here (line 44), CardSSE in realtimeCacheUpdaters.ts (line 11), and the Prisma select + manual mapping in hydrateCard (line 249). The comment/activity shapes have the same problem. Adding a field to the card model requires coordinated changes across all three files plus toSSE() in the route handler. Extract the wire-format payload types into a shared src/lib/realtimeTypes.ts (no server imports needed — they're plain interfaces) so the server hydration, the route toSSE mapper, and the client updaters all reference a single source of truth.

const encoder = new TextEncoder()
let cleanup: (() => void) | null = null

// Build project access set filtered by GitHub repo access.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design & Architecture] suggestion

The access-refresh state machine (refreshAccessibleTeams, isRefreshing, consecutiveRefreshFailures, MAX_REFRESH_FAILURES, accessibleTeamIds) is a non-trivial piece of logic embedded inside the SSE route handler closure. It mixes authorisation concerns (which teams can this user see?) with SSE wiring (stream events to the client). This makes it untestable in isolation and adds ~40 lines of complexity to an already long route. Consider extracting it into a self-contained class or module (e.g. UserAccessScope) that encapsulates the set, refresh interval, failure counting, and fail-closed behaviour. The route handler would then just call scope.teamIds in the filter and scope.maybeRefresh() in the keepalive.


if (event.type === 'session_updated') {
qc.setQueryData<SidebarData>(['sidebar-data'], (old) => {
if (!old) return old
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design & Architecture] suggestion

updateCardCache manually patches three independent query caches (sidebar, board, card-detail) by copying every field from the SSE event. This is 90+ lines of hand-rolled cache synchronisation that must be kept in sync with each cache's shape. For the board and card-detail caches — which are page-scoped and benefit less from sub-second latency — consider using invalidateQueries (triggering a background refetch) instead of manual patching. This would halve the function's size and eliminate a class of bugs where the patched shape drifts from the query's actual return type. Reserve manual setQueryData for the sidebar, where the instant update is visually important.

let cachedProjects: Awaited<ReturnType<typeof getProjects>> | null = null
let projectCacheTime = 0

async function getCachedProjects() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Frontend Snappiness] suggestion

Classic cache stampede: when the 60s TTL expires, every concurrent caller to getCachedProjects() (e.g. multiple SSE connections starting up or hitting their keepalive refresh simultaneously) will all see the stale cache and each independently call getProjects(). Fix by storing the in-flight promise and letting concurrent callers await it:

let inflightPromise: Promise<...> | null = null
async function getCachedProjects() {
  if (cachedProjects && Date.now() - projectCacheTime < PROJECT_CACHE_TTL_MS) return cachedProjects
  if (inflightPromise) return inflightPromise
  inflightPromise = getProjects().then(result => {
    cachedProjects = result
    projectCacheTime = Date.now()
    return result
  }).finally(() => { inflightPromise = null })
  return inflightPromise
}

@review-hero
Copy link
Copy Markdown

review-hero bot commented Apr 15, 2026

🦸 Review Hero Summary
15 agents reviewed this PR | 0 critical | 4 suggestions | 1 nitpick | Filtering: consensus 3 voters, 16 below threshold

Below consensus threshold (16 unique issues not confirmed by majority)
Location Agent Severity Comment
src/app/api/sidebar-events/route.ts:13 Design & Architecture nitpick The module-level getCachedProjects() cache is an application-wide concern (shared project list) trapped inside the SSE route file. If any other server endpoint or action needs the same cached pro...
src/app/api/sidebar-events/route.ts:22 Security suggestion getCachedProjects() is a module-level shared cache with no access control — every SSE connection's access-refresh cycle reads the same project list (including owner, repoName, team IDs). This is ...
src/app/api/sidebar-events/route.ts:55 Frontend Snappiness suggestion Per-connection GitHub API call with no cross-connection deduplication. refreshAccessibleTeams calls filterAccessibleRepos (a GitHub API hit) per SSE connection every 5 minutes. A user with 5 br...
src/app/api/sidebar-events/route.ts:93 Frontend Snappiness critical await refreshAccessibleTeams() runs before the SSE stream is opened, adding the full latency of a DB query + GitHub API call to connection setup. Every browser reconnect (tab switch, network hicc...
src/app/api/sidebar-events/route.ts:140 Frontend Snappiness suggestion The access refresh fires inside the keepalive interval's try block, meaning a slow refreshAccessibleTeams() that takes >30s won't block the next keepalive tick (good), but a GitHub API timeout ...
src/app/api/sidebar-events/route.ts:155 Bugs & Correctness suggestion When refreshAccessibleTeams() fails, the .catch() increments consecutiveRefreshFailures but never updates lastAccessRefresh. Since lastAccessRefresh still holds the timestamp of the last ...
src/app/api/sidebar-events/route.ts:199 Security suggestion The access-refresh .catch() discards all error information — a silently failing auth layer is an observability blind spot. If a user's GitHub token is revoked or the GitHub API starts rejecting r...
src/app/api/sidebar-events/route.ts:200 Security suggestion Comment says "Fail closed" but the implementation fails open for up to 2 consecutive refresh failures. When refreshAccessibleTeams throws (e.g. revoked GitHub token returns 401), the stale `acces...
src/components/card/CardWorkspace.tsx:454 Frontend Snappiness suggestion The two useEffect hooks syncing localPrUrl and localPrNumber from card props will overwrite optimistic local state on every SSE-driven card update, even if the server hasn't yet persisted t...
src/lib/hooks/realtimeCacheUpdaters.ts:64 Frontend Snappiness suggestion agent_start short-circuits with an early if (idx === 0) return old in the old code, avoiding a needless array copy when the session is already at the top. The new code always splices and rebuil...
src/lib/hooks/realtimeCacheUpdaters.ts:68 Frontend Snappiness nitpick updateSessionCache for agent_start prepends new sessions to recentSessions without any cap on array length. Over a long-lived browser session receiving many agent-start events, this array gro...
src/lib/hooks/realtimeCacheUpdaters.ts:117 Bugs & Correctness suggestion updateCardCache for card_updated only visits board caches whose project contains card.teamId (the card's current team). If a card update changes teamId (reassigned to a different team), t...
src/lib/hooks/realtimeCacheUpdaters.ts:134 Design & Architecture suggestion The board card field mapping (id, identifier, title, description, status, priority, tags, assignee, team) is written out identically for both the card_created and card_updated branches. Extract...
src/lib/hooks/realtimeCacheUpdaters.ts:204 Frontend Snappiness suggestion updateCommentCache and updateActivityCache both call qc.getQueriesData<CardDetailData>({ queryKey: ['card-detail'] }) and iterate all cached card-detail entries. For N open card tabs this is ...
src/lib/realtimeEvents.ts:172 Frontend Snappiness suggestion Session events are efficiently routed via subscriptionsByUserId (O(1) lookup), but card/comment/activity events iterate the entire subscriptions set. If the server has many concurrent SSE conne...
src/lib/realtimeEvents.ts:176 Design & Architecture suggestion The concurrency limiter (runWithLimit) silently drops notifications when the queue exceeds MAX_QUEUE_SIZE=200, with only a code comment explaining this. In a sustained burst scenario, clients will ...

Nitpicks

File Line Agent Comment
src/lib/hooks/realtimeCacheUpdaters.ts 1 Project Conventions This module contains pure cache-update functions, not a React hook. Per project structure (src/lib/ — Shared utilities), non-hook utility modules belong in src/lib/ rather than src/lib/hooks/. Consider src/lib/realtimeCacheUpdaters.ts to keep the hooks directory exclusively for use* hooks.
Local fix prompt (copy to your coding agent)

Fix these issues identified on the pull request. One commit per issue fixed.


src/lib/realtimeEvents.ts:44: The card payload shape is independently defined in three places: CardEvent here (line 44), CardSSE in realtimeCacheUpdaters.ts (line 11), and the Prisma select + manual mapping in hydrateCard (line 249). The comment/activity shapes have the same problem. Adding a field to the card model requires coordinated changes across all three files plus toSSE() in the route handler. Extract the wire-format payload types into a shared src/lib/realtimeTypes.ts (no server imports needed — they're plain interfaces) so the server hydration, the route toSSE mapper, and the client updaters all reference a single source of truth.


src/app/api/sidebar-events/route.ts:55: The access-refresh state machine (refreshAccessibleTeams, isRefreshing, consecutiveRefreshFailures, MAX_REFRESH_FAILURES, accessibleTeamIds) is a non-trivial piece of logic embedded inside the SSE route handler closure. It mixes authorisation concerns (which teams can this user see?) with SSE wiring (stream events to the client). This makes it untestable in isolation and adds ~40 lines of complexity to an already long route. Consider extracting it into a self-contained class or module (e.g. UserAccessScope) that encapsulates the set, refresh interval, failure counting, and fail-closed behaviour. The route handler would then just call scope.teamIds in the filter and scope.maybeRefresh() in the keepalive.


src/lib/hooks/realtimeCacheUpdaters.ts:100: updateCardCache manually patches three independent query caches (sidebar, board, card-detail) by copying every field from the SSE event. This is 90+ lines of hand-rolled cache synchronisation that must be kept in sync with each cache's shape. For the board and card-detail caches — which are page-scoped and benefit less from sub-second latency — consider using invalidateQueries (triggering a background refetch) instead of manual patching. This would halve the function's size and eliminate a class of bugs where the patched shape drifts from the query's actual return type. Reserve manual setQueryData for the sidebar, where the instant update is visually important.


src/app/api/sidebar-events/route.ts:22: Classic cache stampede: when the 60s TTL expires, every concurrent caller to getCachedProjects() (e.g. multiple SSE connections starting up or hitting their keepalive refresh simultaneously) will all see the stale cache and each independently call getProjects(). Fix by storing the in-flight promise and letting concurrent callers await it:
ts let inflightPromise: Promise&lt;...> | null = null async function getCachedProjects() { if (cachedProjects && Date.now() - projectCacheTime &lt; PROJECT_CACHE_TTL_MS) return cachedProjects if (inflightPromise) return inflightPromise inflightPromise = getProjects().then(result => { cachedProjects = result projectCacheTime = Date.now() return result }).finally(() => { inflightPromise = null }) return inflightPromise }


src/lib/hooks/realtimeCacheUpdaters.ts:1: This module contains pure cache-update functions, not a React hook. Per project structure (src/lib/ — Shared utilities), non-hook utility modules belong in src/lib/ rather than src/lib/hooks/. Consider src/lib/realtimeCacheUpdaters.ts to keep the hooks directory exclusively for use* hooks.

@edmofro edmofro merged commit 0dce4aa into main Apr 15, 2026
15 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant