operations.md describes two incompatible event-stream designs

This commit is contained in:
Ian Gulliver
2026-10-16 14:33:31 -07:00
parent ca24f2cabb
commit 9d87139b72
2 changed files with 4 additions and 7 deletions
+4 -4
View File
@@ -4,7 +4,7 @@
- `cmd/byre`: `byre serve` runs the server; `byre run` drives one Claude session in the current directory from the terminal.
- `api/openapi.yaml` is the API source of truth. `go generate ./internal/api` regenerates `internal/api/api.gen.go`, whose types are also the store's model types.
- `internal/store`: SQLite (WAL). Every mutation and its event commit in one transaction; events are published to SSE subscribers in `seq` order.
- `internal/store`: SQLite (WAL). Every mutation a client sees commits in one transaction with its event, and events are published to SSE subscribers in `seq` order. Internal bookkeeping writes commit with no event of their own: user rows from `EnsureUser`, blob rows, a merge's `finalizing_commit`, `finalizing_notes` and `finalizing_unchanged`, and the `review_requests` counter, whose effect shows only through the `task.state` it can emit.
- `internal/server`: HTTP handlers, identity, SSE, the worktree file browser (see "Files"), terminals (see "Terminals").
- `internal/git`: worktrees, the task changes view (see "Changes"), snapshots (see "Snapshots"), unmerged-change summaries, squash merges, merge notes and pushes.
- `internal/agents`: the agent session manager. It owns the running agent processes, task agents, lead agents (see "Lead agent") and reviewers (see "Reviews") alike, and records their activity as events.
@@ -322,8 +322,8 @@ A workflow is a named graph of stages, each a stage name, the stages it comes af
## Events
`GET /api/events` is server-sent events: `id` is the event's `seq`, `data` the Event JSON. Reconnecting with `Last-Event-ID` (EventSource does this itself) or `?after=` replays everything after that seq before going live. `?task_id=` limits the stream, replay and live, to one task's events (404 for an unknown task); `?lead=true` to the events of the caller's lead agent sessions. An event with an `owner` (a lead agent's events, a user's `focus.updated`, `lead.note_saved` and `lead.note_deleted`) goes only to that user's streams; the others never see it. Unstored events (`seq` 0, currently only `agent.delta`) go out live only, without an `id` line, so they don't move `Last-Event-ID`. A client that falls further behind than its subscriber buffer (`subscribe` in `internal/server/hub.go`) is disconnected and catches up by reconnecting. Every 15s the server sends an `event: ping` so clients can tell a live stream from a dead one. Once the replay is written, before anything live, the server sends an `event: ready`.
`GET /api/events` is server-sent events: `id` is the event's `seq`, `data` the Event JSON. Reconnecting with `Last-Event-ID` (EventSource does this itself) or `?after=` replays everything after that seq before going live. `?task_id=` limits the stream, replay and live, to one task's events (404 for an unknown task); `?lead=true` to the events of the caller's lead agent sessions. An event with an `owner` (a lead agent's events, a user's `focus.updated`, `lead.note_saved` and `lead.note_deleted`) goes only to that user's streams; the others never see it. Unstored events (`seq` 0, sent with `Store.Broadcast`: `agent.delta` and `admin.health`) go out live only, without an `id` line, so they don't move `Last-Event-ID`. A client that falls further behind than its subscriber buffer (`subscribe` in `internal/server/hub.go`) is disconnected and catches up by reconnecting. Every 15s the server sends an `event: ping` so clients can tell a live stream from a dead one. Once the replay is written, before anything live, the server sends an `event: ready`.
The page keeps every active task's events in memory, one `Feed` per task (`taskFeeds` in `web/src/state.ts`), so switching tasks makes no request. Each `loadTasks` creates a feed for any active task without one, which pages through `GET /api/tasks/{taskId}/events` (1000 at a time) for its history; selecting a task that has no feed (a closed one) does the same. Live events reach every feed through the page's one global stream, which already carries every task's unowned events; a feed holds back what arrives while its history loads and drops the duplicates by `seq`. Feeds of closed tasks are dropped once they aren't selected. A failed history load is logged to the console and leaves the feed waiting for its history; the next `loadTasks` or selection of that task loads it again into the same feed. A feed takes its history, then the live events held back during the load, skipping each event whose `seq` is at or below the last one it kept, so an event in both is kept once. The lead's feed replays its own `?lead=true` stream and holds back what arrives until that stream's first `ready`. Either way the whole history reaches the transcript at once, so it draws in one pass, already scrolled to the bottom; until then the transcript shows "loading…". The web UI (`subscribe` in `web/src/api.ts`) opens no stream until the window's `load` event has fired, since WebKit counts an EventSource opened earlier as part of the page load and shows its loading indicator in place of the favicon for as long as the stream stays open. It tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when `tailscale serve` answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the left pane's tab strip shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it.
The web UI keeps two streams. The global one (`subscribe` in `main`, `web/src/app.ts`), opened after the `last_event_seq` of `GET /api/admin/status`, carries every event the user may see from the page's load onward; `handleEvent` (`web/src/state.ts`) drives the task, session, inbox and lead session lists, the right pane's focus and the Status tab from it, and hands each event with a `task_id` to that task's feed. The lead's (`startLeadStream`), opened with `?lead=true` and `after=0`, replays the caller's lead sessions' whole history and then stays live. There is no per-task stream: selecting a task opens nothing. Only the global stream drives the connection dot.
The web UI keeps three streams: one for all events from the page's load onward, which drives the task, session, inbox and lead session lists, the right pane's focus and the connection dot; one per selected task (`selectTask` in `web/src/state.ts`), opened with `?task_id=` and `after=0` so it replays the task's whole history and then stays live; and one for the lead (`startLeadStream`), opened with `?lead=true` and `after=0`. Each transcript is drawn only from its own stream, so a reconnect after a byre restart fills in both the history and anything missed. Selecting another task closes the old task stream. Events arriving together are drawn in one batch.
The page keeps every active task's events in memory, one `Feed` per task (`taskFeeds` in `web/src/state.ts`), so switching tasks makes no request. Each `loadTasks` creates a feed for any active task without one, which pages through `GET /api/tasks/{taskId}/events` (1000 at a time) for its history; selecting a task that has no feed (a closed one) does the same. Live events reach every feed through the global stream; a feed holds back what arrives while its history loads and drops the duplicates by `seq`. A task feed keeps unstored events (`agent.delta`) only while its history has loaded and its task is selected, and passes them straight to the transcript's streaming view without storing them; a stored event is drawn only from the feed, so a reconnect after a byre restart fills in what was missed through the global stream's replay. Feeds of closed tasks are dropped once they aren't selected. A failed history load is logged to the console and leaves the feed waiting for its history; the next `loadTasks` or selection of that task loads it again into the same feed. A feed takes its history, then the live events held back during the load, skipping each event whose `seq` is at or below the last one it kept, so an event in both is kept once. The lead's feed replays its own `?lead=true` stream and holds back what arrives until that stream's first `ready`. Either way the whole history reaches the transcript at once, so it draws in one pass, already scrolled to the bottom; until then the transcript shows "loading…". The web UI (`subscribe` in `web/src/api.ts`) opens no stream until the window's `load` event has fired, since WebKit counts an EventSource opened earlier as part of the page load and shows its loading indicator in place of the favicon for as long as the stream stays open. It tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when `tailscale serve` answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the left pane's tab strip shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it. A feed collects live events and flushes them on a zero-delay timer, so events arriving together are drawn in one batch.