| 1 |
# Live pages |
| 2 |
|
| 3 |
Status: design, 2026-10-14 (oxygenhub#202). Nothing here is built yet |
| 4 |
beyond what §2 lists. |
| 5 |
|
| 6 |
## 1. The problem |
| 7 |
|
| 8 |
Pages are rendered once. When something happens behind them (an |
| 9 |
assistant answers, a run finishes, a review arrives, a bump is opened) |
| 10 |
the page does not know until it is reloaded. Proposal 38 on |
| 11 |
doomsdayone/bomberman94 showed it: the assistant's answer, or the reason |
| 12 |
there was none, only appeared on reload. |
| 13 |
|
| 14 |
## 2. What exists |
| 15 |
|
| 16 |
- **Flow runs** stream their logs and job states over Server-Sent Events |
| 17 |
(`/{owner}/{name}/flow/{id}/live`, read by `EventSource` in |
| 18 |
`assets/hub.js`). |
| 19 |
- **The conflict resolver** keeps in-memory rooms (`crates/hub/src/live.rs`): |
| 20 |
a `tokio::sync::broadcast` channel per conflict, for presence and |
| 21 |
choices. |
| 22 |
- **Notifications and webhooks** already describe most writes as events |
| 23 |
(`notify::deliver`, `hooks::emit`). |
| 24 |
|
| 25 |
## 3. Not a single-page app |
| 26 |
|
| 27 |
Moving to Next.js or another single-page app was considered and set |
| 28 |
aside: |
| 29 |
|
| 30 |
- every page would be rendered twice (Rust templates and JavaScript), and |
| 31 |
the two drift; |
| 32 |
- public pages would still need server rendering; |
| 33 |
- pages would stop working without JavaScript, and accessibility gets |
| 34 |
harder; |
| 35 |
- permissions would be checked in two places; |
| 36 |
- it rewrites about a hundred templates to gain one thing: pages that |
| 37 |
update themselves. |
| 38 |
|
| 39 |
## 4. The design: notify, then fetch |
| 40 |
|
| 41 |
1. **Events name a topic, not data.** Every write publishes a small |
| 42 |
event: a topic, a version, and a kind. Topics: |
| 43 |
- `repo:<id>` (bookmarks moved, settings); |
| 44 |
- `repo:<id>/proposal:<n>` and `repo:<id>/issue:<n>` (comments, |
| 45 |
reviews, assistant answers, labels, state, checks); |
| 46 |
- `run:<id>` (jobs and logs, as today); |
| 47 |
- `user:<id>` (inbox, dashboard); |
| 48 |
- `diffusion:<repo id>` (bumps, pills). |
| 49 |
2. **One stream per page.** A page asks for its topics on one |
| 50 |
connection: `GET /live?topics=...`, `text/event-stream`. The browser |
| 51 |
reconnects by itself; `Last-Event-ID` resumes, and the server replays |
| 52 |
what was missed from a short buffer, or says "reload" when it cannot. |
| 53 |
3. **The page fetches what changed.** An event says which part of the |
| 54 |
page is stale. The page fetches that part as HTML from the normal |
| 55 |
route (`?fragment=comments`, `?fragment=checks` and so on) and |
| 56 |
swaps it in. So: |
| 57 |
- the templates that render the page render the update; |
| 58 |
- permission is checked once, by the route that renders the |
| 59 |
fragment; an event reaching the wrong viewer reveals nothing but a |
| 60 |
topic and a version; |
| 61 |
- fragments can be cached and are tested like pages. |
| 62 |
4. **Subscribing is checked too.** `/live` only accepts topics the viewer |
| 63 |
may read (a private repository's topics need read access), so even |
| 64 |
the fact that something changed does not leak. |
| 65 |
5. **The page still works without it.** No JavaScript or no connection: |
| 66 |
the page is what it is today, and a reload shows everything. Pending |
| 67 |
work shows a placeholder ("Claude is reviewing…") that the stream |
| 68 |
replaces. |
| 69 |
6. **A small client, ours.** About 2 KB in `hub.js`: elements carry |
| 70 |
`data-live="<topic>"` and `data-fragment="<name>"`; an event for the |
| 71 |
topic refetches the fragment and swaps it, keeping focus, scroll and |
| 72 |
anything being typed. No framework. |
| 73 |
|
| 74 |
## 5. At scale, across services |
| 75 |
|
| 76 |
When the hub is split into services: |
| 77 |
|
| 78 |
- **Services publish events to a durable log.** NATS JetStream is the |
| 79 |
choice: small, self-hosted, clustered, with replay, so a sovereign site |
| 80 |
runs it on its own machines. Postgres `LISTEN/NOTIFY` is an acceptable |
| 81 |
first step for a single database. |
| 82 |
- **A stateless live gateway** holds the browsers' connections. It |
| 83 |
checks a viewer's subscription (asking the owning service, cached |
| 84 |
briefly), relays topic events, and replays from the log for |
| 85 |
`Last-Event-ID`. It never renders and never reads repositories, so it |
| 86 |
scales out on its own; losing an instance means browsers reconnect. |
| 87 |
- **Fragments stay with the service that owns the data** (issues, |
| 88 |
proposals, Flow, Diffusion). Moving a page to a new service does not |
| 89 |
touch the live layer. |
| 90 |
- **The resolver's rooms** become topics on the log, so people on |
| 91 |
different instances see each other. |
| 92 |
- **Later:** WebTransport where streams need to be bidirectional and |
| 93 |
many; SSE stays the default. |
| 94 |
|
| 95 |
## 5a. Status |
| 96 |
|
| 97 |
- **L1 (done, #203):** `events::LiveBus` in the hub: one broadcast of |
| 98 |
`{id, topic, version, kind}` that subscribers filter by topic (a channel |
| 99 |
per topic buys nothing at this size), a version per topic, and the last |
| 100 |
1024 events for `Last-Event-ID` (older: "reload"). Published from |
| 101 |
`hooks::emit` (the repository's topic, and the proposal's or issue's the |
| 102 |
event names), `notify::tell` (`user:<id>`), Flow job changes |
| 103 |
(`run:<id>`) and Diffusion bumps (`diffusion:<repo id>`, both sides). |
| 104 |
|
| 105 |
- **L2 (done, #204):** `GET /live?topics=a,b` (1 to 16 topics; every one |
| 106 |
must be one the viewer may hear, read access to its repository or, for |
| 107 |
`user:<id>`, being that user, or the request is refused whole, saying |
| 108 |
nothing of which); SSE `change` events `{topic, version, kind}` with the |
| 109 |
bus id as the event id; `Last-Event-ID` replays the missed events on |
| 110 |
those topics or sends `reload`, as does falling behind the broadcast. |
| 111 |
The client in `hub.js`: `data-live` + `data-fragment` elements refetch |
| 112 |
`?fragment=<name>` from the page's own route and swap it in, waiting |
| 113 |
while someone types inside one. |
| 114 |
|
| 115 |
- **L3 (done, #205):** the proposal page updates itself. Its timeline |
| 116 |
(comments, reviews, assistant answers, events, the landed or closed |
| 117 |
outcome, and a "… is answering" placeholder while an assistant works) |
| 118 |
listens on `repo:<id>/proposal:<n>`, and its Landing section on that and |
| 119 |
`repo:<id>` (check results). `?fragment=timeline|landing` renders the page |
| 120 |
through the same route and returns the part between |
| 121 |
`<!--frag:NAME-->` markers (`web::fragment`), so the update is the page. |
| 122 |
A comment, an assistant asked, answering or not answering, reviews, state |
| 123 |
changes and landings publish on the proposal's topic. |
| 124 |
|
| 125 |
- **L4 (done, #206):** the top bar's notification count listens on the |
| 126 |
viewer's `user:<id>` topic on every page (the Shell carries it), the |
| 127 |
inbox's list on it too, and the dashboard's cards and activity feed on |
| 128 |
it and the topics of up to 15 of the viewer's repositories (a stream |
| 129 |
takes 16). Nothing new on the server: the client refetches the page and |
| 130 |
swaps each marked part, so every route already serves them. |
| 131 |
|
| 132 |
- **L5 (done, #207):** the issue page's timeline listens on |
| 133 |
`repo:<id>/issue:<n>` and `repo:<id>`; the repository overview's latest |
| 134 |
change and bookmarks on `repo:<id>`, and its Diffusion card and the |
| 135 |
Diffusion tab on `diffusion:<repo id>` (the tab on `repo:<id>` too). |
| 136 |
The repository chrome (`RepoShell`) carries the id for these topics. |
| 137 |
|
| 138 |
- **Across processes (oxygenhub#273):** the live bus rides the hub's event |
| 139 |
bus (docs/roles.md, "Event bus"). On PostgreSQL every web process hears |
| 140 |
every other's events over `LISTEN/NOTIFY` and numbers them in its own |
| 141 |
replay buffer, so a page's `Last-Event-ID` belongs to the node that gave |
| 142 |
it; reconnecting elsewhere with an id that node never gave sends |
| 143 |
`reload`. The resolver's choices and landings travel the same way; |
| 144 |
presence stays per node. |
| 145 |
|
| 146 |
## 6. Order |
| 147 |
|
| 148 |
|
| 149 |
|
| 150 |
| 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 | |
| 151 |
| L2 | `GET /live?topics=` with subscription checks, `Last-Event-ID` replay, and the `hub.js` client with `data-live` / `data-fragment` | #204 | |
| 152 |
| L3 | The proposal page live: comments, reviews, assistant answers and pending placeholders, checks, landing state | #205 | |
| 153 |
| L4 | Dashboard and inbox live, with the notification count in the top bar | #206 | |
| 154 |
| L5 | Issue page, Diffusion tab and repository overview live | #207 | |
| 155 |
| L6 | Fold the Flow run stream and the resolver's rooms into the same bus | #208 | |
| 156 |
| L7 | Services: JetStream behind the bus and the live gateway as its own service | #209 | |