Adopt shared SSE channel across UI: previews, sessions, workers (+ artifacts via #34) #35

Open
opened 2026-05-28 22:32:45 +02:00 by lz · 0 comments
Owner

Context

Companion to issue #34 (artifact polling → SSE). When deciding how to design that channel, it's wasteful to design it as a one-off — the same machinery should cover every poll point in the app. A codebase survey turned up the following picture: one root setInterval(10s) in +page.svelte drives a tick that fans out to WorkersSessionsPreviews, plus a separate 5s timer inside ArtifactsStore. Every meaningful client-side refresh in the app rides one of those two timers.

Polling sites found

File:line Polls Cadence Event source UX impact Effort
nexus/src/routes/+page.svelte:14 Root tick that fans out to Workers/Sessions/Previews 10s (skipped when typing in session-add) Container state change, tmux window list change, session DB CRUD Medium — "I clicked Stop, did it work?" lag. Status dots feel stale Design question — this is the spine
nexus/src/lib/components/Workers.svelte:39,75 GET /api/workers + per-worker …/health + …/sessions every parent tick (10s) Worker created/removed/started/stopped; container state change; workspace_shell open/close Medium — running/errored dot, "X running · Y total" pill Cheap port: server already has checkWorkerHealth callable on Docker events
nexus/src/lib/components/Sessions.svelte:44,69 GET /api/workers/:id/sessions + per-session …/health (TTL-gated to 8s) every parent tick (10s) Session created/removed; tmux window appearance/disappearance; claude process up/down High — operator launches a session, waits up to 10s to see green dot Cheap port: reconcileSessions already emits the deltas we'd push
nexus/src/lib/components/Previews.svelte:19,28 GET /api/workers/:id/sessions/:sid/previews every parent tick (10s) notify-preview callback creates a pending row; operator approves/revokes Highnotify-preview from inside an agent shell is the canonical use case; up to 10s before the operator sees the new pending row Cheap port: PreviewProxy already owns the lifecycle events
nexus/src/lib/artifacts-store.svelte.ts:33 GET /api/workers/:id/sessions/:sid/artifacts 5s notify-artifact creates row; pin/unpin via UI High — issue #34; up to 7s (5s poll + 2s burst) Already scheduled

(Not migration candidates: SessionTerminal already uses a WebSocket; LogsModal is one-shot on open; the per-artifact pop-out explicitly disables list polling; FileExplorer refetches only after its own mutations.)

Strong SSE candidates

  • Previews — pending rows from notify-preview are an explicit operator-action prompt; latency directly degrades the agent's UX inside a shell.
  • Sessions list + per-session healthreconcileSessions already produces the exact delta-stream (window-appeared, window-gone-for-2-polls); pushing it skips both the client 8s TTL gate and the 10s tick.
  • Artifacts list — already covered by issue #34. Keep here for completeness; the shared channel handles it.
  • Workers list + health — Docker exposes docker events for container start/stop/die; combined with internal create/remove calls in workers/service.ts, the source events already exist. Slightly bigger lift than the others because we need a Docker-events listener.

Marginal candidates

  • Workspace-shell open/close indicator (currently piggybacks Workers.load's listSessions call) — included for free if sessions go SSE; not worth its own channel.
  • Phase/onboarding state (needs-setup → ready) — never auto-refetched; only mutated by explicit user action that already calls invalidateAll. Don't bother.

Leave-as-polling

  • nexus/src/server.ts:31 skills-installer staging sweep — server-internal janitor, 5-minute cadence is fine.
  • checkWorkerHealth invoking reconcileSessions — server-internal; once the client switches to SSE this becomes the producer, not a consumer.

One shared endpoint, GET /api/stream (SSE), with topic envelopes. Rationale:

  • All four candidate domains share the same auth boundary (the hooks.server.ts cookie gate already protects /api/*).
  • One operator, one tab usually — connection budget is small either way.
  • Same reconnect semantics: drop-and-resubscribe; no per-topic resume cursors needed because the client can always re-GET the canonical list endpoint on (re)connect to reconcile.
  • One TCP connection per tab instead of four (browsers cap SSE/HTTP1.1 at 6 per origin, which matters once we add a second window or the pop-out artifact viewer).
  • Filtering happens server-side via a query param (?topics=workers,sessions:<wid>,previews:<sid>,artifacts:<sid>) so the client only pays bytes for what it subscribes to.
  • One connect/disconnect log, one heartbeat timer — cheaper to instrument and avoids designing four nearly identical bus implementations.

Suggested rollout order

  1. Artifacts (issue #34). Lowest risk — the producer is a single HTTP callback (notify-artifact), no Docker-events wiring needed. Builds out the shared /api/stream plumbing (envelope shape, server-side bus, client subscription helper) without coupling to the worker poll cascade. Validates topic-filter design end-to-end.
  2. Previews. Same pattern as artifacts (single notify-preview HTTP entry point + UI approve/revoke mutations). Lets us delete Previews.svelte's tick subscription independently.
  3. Sessions (list + health). reconcileSessions already runs on every poll; tee its delta output onto the bus. Detaching from the tick chain materially improves perceived session-creation latency.
  4. Workers (list + health). Requires the new bit: a docker events subscriber feeding container-state changes into the bus. Once this lands, nexus/src/routes/+page.svelte's root setInterval(10_000) can be deleted entirely, and the tick prop drops out of MainSplit/Workers/Sessions/Previews.

Why open this instead of bundling into #34

Issue #34's scope is narrow and ship-now-ish — the SSE infrastructure itself is what's worth designing once. This issue is the larger architectural picture so reviewers of #34 can see what the channel is being designed for, not just the immediate use case.

## Context Companion to issue #34 (artifact polling → SSE). When deciding how to design that channel, it's wasteful to design it as a one-off — the same machinery should cover every poll point in the app. A codebase survey turned up the following picture: one root `setInterval(10s)` in [`+page.svelte`](nexus/src/routes/+page.svelte) drives a tick that fans out to `Workers` → `Sessions` → `Previews`, plus a separate 5s timer inside `ArtifactsStore`. Every meaningful client-side refresh in the app rides one of those two timers. ## Polling sites found | File:line | Polls | Cadence | Event source | UX impact | Effort | |-----------|-------|---------|--------------|-----------|--------| | [`nexus/src/routes/+page.svelte:14`](nexus/src/routes/+page.svelte#L14) | Root tick that fans out to Workers/Sessions/Previews | 10s (skipped when typing in session-add) | Container state change, tmux window list change, session DB CRUD | Medium — "I clicked Stop, did it work?" lag. Status dots feel stale | Design question — this is the spine | | [`nexus/src/lib/components/Workers.svelte:39,75`](nexus/src/lib/components/Workers.svelte#L39) | `GET /api/workers` + per-worker `…/health` + `…/sessions` | every parent tick (10s) | Worker created/removed/started/stopped; container state change; workspace_shell open/close | Medium — running/errored dot, "X running · Y total" pill | Cheap port: server already has `checkWorkerHealth` callable on Docker events | | [`nexus/src/lib/components/Sessions.svelte:44,69`](nexus/src/lib/components/Sessions.svelte#L44) | `GET /api/workers/:id/sessions` + per-session `…/health` (TTL-gated to 8s) | every parent tick (10s) | Session created/removed; tmux window appearance/disappearance; claude process up/down | High — operator launches a session, waits up to 10s to see green dot | Cheap port: `reconcileSessions` already emits the deltas we'd push | | [`nexus/src/lib/components/Previews.svelte:19,28`](nexus/src/lib/components/Previews.svelte#L19) | `GET /api/workers/:id/sessions/:sid/previews` | every parent tick (10s) | `notify-preview` callback creates a `pending` row; operator approves/revokes | **High** — `notify-preview` from inside an agent shell is the canonical use case; up to 10s before the operator sees the new pending row | Cheap port: `PreviewProxy` already owns the lifecycle events | | [`nexus/src/lib/artifacts-store.svelte.ts:33`](nexus/src/lib/artifacts-store.svelte.ts#L33) | `GET /api/workers/:id/sessions/:sid/artifacts` | 5s | `notify-artifact` creates row; pin/unpin via UI | **High** — issue #34; up to 7s (5s poll + 2s burst) | Already scheduled | (Not migration candidates: `SessionTerminal` already uses a WebSocket; `LogsModal` is one-shot on open; the per-artifact pop-out explicitly disables list polling; `FileExplorer` refetches only after its own mutations.) ## Strong SSE candidates - **Previews** — pending rows from `notify-preview` are an explicit operator-action prompt; latency directly degrades the agent's UX inside a shell. - **Sessions list + per-session health** — `reconcileSessions` already produces the exact delta-stream (window-appeared, window-gone-for-2-polls); pushing it skips both the client 8s TTL gate and the 10s tick. - **Artifacts list** — already covered by issue #34. Keep here for completeness; the shared channel handles it. - **Workers list + health** — Docker exposes `docker events` for container start/stop/die; combined with internal create/remove calls in `workers/service.ts`, the source events already exist. Slightly bigger lift than the others because we need a Docker-events listener. ## Marginal candidates - **Workspace-shell open/close indicator** (currently piggybacks `Workers.load`'s `listSessions` call) — included for free if sessions go SSE; not worth its own channel. - **Phase/onboarding state** (`needs-setup → ready`) — never auto-refetched; only mutated by explicit user action that already calls `invalidateAll`. Don't bother. ## Leave-as-polling - [`nexus/src/server.ts:31`](nexus/src/server.ts#L31) skills-installer staging sweep — server-internal janitor, 5-minute cadence is fine. - `checkWorkerHealth` invoking `reconcileSessions` — server-internal; once the client switches to SSE this becomes the *producer*, not a consumer. ## Recommended channel design **One shared endpoint, `GET /api/stream` (SSE), with topic envelopes.** Rationale: - All four candidate domains share the same auth boundary (the `hooks.server.ts` cookie gate already protects `/api/*`). - One operator, one tab usually — connection budget is small either way. - Same reconnect semantics: drop-and-resubscribe; no per-topic resume cursors needed because the client can always re-`GET` the canonical list endpoint on (re)connect to reconcile. - One TCP connection per tab instead of four (browsers cap SSE/HTTP1.1 at 6 per origin, which matters once we add a second window or the pop-out artifact viewer). - Filtering happens server-side via a query param (`?topics=workers,sessions:<wid>,previews:<sid>,artifacts:<sid>`) so the client only pays bytes for what it subscribes to. - One connect/disconnect log, one heartbeat timer — cheaper to instrument and avoids designing four nearly identical bus implementations. ## Suggested rollout order 1. **Artifacts** (issue #34). Lowest risk — the producer is a single HTTP callback (`notify-artifact`), no Docker-events wiring needed. Builds out the shared `/api/stream` plumbing (envelope shape, server-side bus, client subscription helper) without coupling to the worker poll cascade. Validates topic-filter design end-to-end. 2. **Previews**. Same pattern as artifacts (single `notify-preview` HTTP entry point + UI approve/revoke mutations). Lets us delete `Previews.svelte`'s tick subscription independently. 3. **Sessions** (list + health). `reconcileSessions` already runs on every poll; tee its delta output onto the bus. Detaching from the `tick` chain materially improves perceived session-creation latency. 4. **Workers** (list + health). Requires the new bit: a `docker events` subscriber feeding container-state changes into the bus. Once this lands, [`nexus/src/routes/+page.svelte`](nexus/src/routes/+page.svelte)'s root `setInterval(10_000)` can be deleted entirely, and the `tick` prop drops out of `MainSplit/Workers/Sessions/Previews`. ## Why open this instead of bundling into #34 Issue #34's scope is narrow and ship-now-ish — the SSE infrastructure itself is what's worth designing once. This issue is the larger architectural picture so reviewers of #34 can see what the channel is being designed for, not just the immediate use case.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#35
No description provided.