# Live pages Status: design, 2026-10-14 (oxygenhub#202). Nothing here is built yet beyond what §2 lists. ## 1. The problem Pages are rendered once. When something happens behind them (an assistant answers, a run finishes, a review arrives, a bump is opened) the page does not know until it is reloaded. Proposal 38 on doomsdayone/bomberman94 showed it: the assistant's answer, or the reason there was none, only appeared on reload. ## 2. What exists - **Flow runs** stream their logs and job states over Server-Sent Events (`/{owner}/{name}/flow/{id}/live`, read by `EventSource` in `assets/hub.js`). - **The conflict resolver** keeps in-memory rooms (`crates/hub/src/live.rs`): a `tokio::sync::broadcast` channel per conflict, for presence and choices. - **Notifications and webhooks** already describe most writes as events (`notify::deliver`, `hooks::emit`). ## 3. Not a single-page app Moving to Next.js or another single-page app was considered and set aside: - every page would be rendered twice (Rust templates and JavaScript), and the two drift; - public pages would still need server rendering; - pages would stop working without JavaScript, and accessibility gets harder; - permissions would be checked in two places; - it rewrites about a hundred templates to gain one thing: pages that update themselves. ## 4. The design: notify, then fetch 1. **Events name a topic, not data.** Every write publishes a small event: a topic, a version, and a kind. Topics: - `repo:` (bookmarks moved, settings); - `repo:/proposal:` and `repo:/issue:` (comments, reviews, assistant answers, labels, state, checks); - `run:` (jobs and logs, as today); - `user:` (inbox, dashboard); - `diffusion:` (bumps, pills). 2. **One stream per page.** A page asks for its topics on one connection: `GET /live?topics=...`, `text/event-stream`. The browser reconnects by itself; `Last-Event-ID` resumes, and the server replays what was missed from a short buffer, or says "reload" when it cannot. 3. **The page fetches what changed.** An event says which part of the page is stale. The page fetches that part as HTML from the normal route (`?fragment=comments`, `?fragment=checks` and so on) and swaps it in. So: - the templates that render the page render the update; - permission is checked once, by the route that renders the fragment; an event reaching the wrong viewer reveals nothing but a topic and a version; - fragments can be cached and are tested like pages. 4. **Subscribing is checked too.** `/live` only accepts topics the viewer may read (a private repository's topics need read access), so even the fact that something changed does not leak. 5. **The page still works without it.** No JavaScript or no connection: the page is what it is today, and a reload shows everything. Pending work shows a placeholder ("Claude is reviewing…") that the stream replaces. 6. **A small client, ours.** About 2 KB in `hub.js`: elements carry `data-live=""` and `data-fragment=""`; an event for the topic refetches the fragment and swaps it, keeping focus, scroll and anything being typed. No framework. ## 5. At scale, across services When the hub is split into services: - **Services publish events to a durable log.** NATS JetStream is the choice: small, self-hosted, clustered, with replay, so a sovereign site runs it on its own machines. Postgres `LISTEN/NOTIFY` is an acceptable first step for a single database. - **A stateless live gateway** holds the browsers' connections. It checks a viewer's subscription (asking the owning service, cached briefly), relays topic events, and replays from the log for `Last-Event-ID`. It never renders and never reads repositories, so it scales out on its own; losing an instance means browsers reconnect. - **Fragments stay with the service that owns the data** (issues, proposals, Flow, Diffusion). Moving a page to a new service does not touch the live layer. - **The resolver's rooms** become topics on the log, so people on different instances see each other. - **Later:** WebTransport where streams need to be bidirectional and many; SSE stays the default. ## 5a. Status - **L1 (done, #203):** `events::LiveBus` in the hub: one broadcast of `{id, topic, version, kind}` that subscribers filter by topic (a channel per topic buys nothing at this size), a version per topic, and the last 1024 events for `Last-Event-ID` (older: "reload"). Published from `hooks::emit` (the repository's topic, and the proposal's or issue's the event names), `notify::tell` (`user:`), Flow job changes (`run:`) and Diffusion bumps (`diffusion:`, both sides). - **L2 (done, #204):** `GET /live?topics=a,b` (1 to 16 topics; every one must be one the viewer may hear, read access to its repository or, for `user:`, being that user, or the request is refused whole, saying nothing of which); SSE `change` events `{topic, version, kind}` with the bus id as the event id; `Last-Event-ID` replays the missed events on those topics or sends `reload`, as does falling behind the broadcast. The client in `hub.js`: `data-live` + `data-fragment` elements refetch `?fragment=` from the page's own route and swap it in, waiting while someone types inside one. - **L3 (done, #205):** the proposal page updates itself. Its timeline (comments, reviews, assistant answers, events, the landed or closed outcome, and a "… is answering" placeholder while an assistant works) listens on `repo:/proposal:`, and its Landing section on that and `repo:` (check results). `?fragment=timeline|landing` renders the page through the same route and returns the part between `` markers (`web::fragment`), so the update is the page. A comment, an assistant asked, answering or not answering, reviews, state changes and landings publish on the proposal's topic. ## 6. Order | Step | What | Issue | |---|---|---| | L1 | Event bus inside the hub (broadcast per topic, versions, a short replay buffer); publish from the writes that notify and webhooks already see | #203 | | L2 | `GET /live?topics=` with subscription checks, `Last-Event-ID` replay, and the `hub.js` client with `data-live` / `data-fragment` | #204 | | L3 | The proposal page live: comments, reviews, assistant answers and pending placeholders, checks, landing state | #205 | | L4 | Dashboard and inbox live, with the notification count in the top bar | #206 | | L5 | Issue page, Diffusion tab and repository overview live | #207 | | L6 | Fold the Flow run stream and the resolver's rooms into the same bus | #208 | | L7 | Services: JetStream behind the bus and the live gateway as its own service | #209 |