Repository navigation
Console: the worker's Events tab scrolls back through its history #911
Description
Part of #906.
Scope
The worker page's Events tab shows that worker's kept history, back to its start or to the console's event cap, whichever comes first: MAX_EVENTS as the console has it when this lands (10,000 today; #917 makes it 1,000). It fills the page and loads older events as the operator scrolls, the way Activity does. To make that cheap on a gateway, GET /v1/events gains a worker filter, served from a new index on the history's events table.
In short
Today the Events tab shows the last hour of the events the console already holds, filtered in the browser, in a list that grows with the page. After this PR it is the same scrolling feed as Activity, holding only this worker's events. It asks the daemon for them 100 at a time, with GET /v1/events?worker=<workerId>&before=<eventId>&limit=100. Every read the tab's feed makes carries worker: the first since=1h, the gap after a stream reconnect, and each older page. At the cap the feed stops and its end row says Showing the newest <cap> events., as Activity's does. With none, the tab says No events for this worker yet. (today: No events for this worker in the last hour.).
flowchart LR
T[Events tab] -->|"changed: ?worker=w1&limit=100"| G[GET /v1/events]
T -->|"scroll near the end: ?worker=w1&before=id&limit=100"| G
G --> H["history: only payload.workerId = w1 (changed)"]
S[live event stream] -->|"filtered by workerId, as today"| T
The new query parameter:
| Daemon, query | Today | After |
|---|---|---|
Gateway, ?worker=w1&limit=100 |
worker ignored: the newest 100 events of the fleet |
the newest 100 events whose payload has workerId: "w1", worker.rejected excluded |
Gateway, ?worker=w1, a worker.rejected whose claimed workerId is w1 |
ignored | not returned: a refused dial's id is only a claim |
Gateway, ?worker=w1&before=e9&limit=100 |
ignored | the 100 events of w1 older than e9 |
Gateway, ?worker=w1&since=1h |
ignored | w1's events of the last hour |
Gateway, ?worker=nobody |
ignored | {"events":[]} |
Gateway, ?worker=w1 alone |
ignored: the recent events in memory | w1's recent events in memory |
Worker whose id is w1, ?worker=w1 |
ignored | every event, as without worker |
Worker whose id is w1, ?worker=w2 |
ignored | {"events":[]} |
Any, ?worker= (empty) |
ignored | 400, message worker must be a worker id of 1 to 128 characters |
Any, worker of 129 characters |
ignored | 400, same message |
"Worker id" here: the id GET /v1/workers gives a worker. "Payload workerId": the field a gateway adds to every event it takes from a worker.
If this goes wrong, a user would see another worker's events on the tab, or the tab stop scrolling back before the history's start.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | GET /v1/events accepts before, limit, since only |
src/http/admin-declarations.ts:145-149 |
| F2 | The handler parses them with eventsPageQuery and dispatches events.replay with { beforeId?, limit?, sinceTs? } |
src/http/app.ts:691-702; src/contract/events-page.ts:18-37 |
| F3 | events.replay's input is { beforeId?, limit? (1–500), sinceTs? }, beforeId/limit not with sinceTs |
src/contract/operations.ts:683-695 |
| F4 | Both dispatchers answer events.replay the same way: no beforeId and no limit → eventHistory.replay({ sinceTs? }); otherwise replayPage({ beforeId?, limit }) |
src/daemon/dispatcher.ts:840-851; src/gateway/dispatcher.ts:372-383 |
| F5 | replayPage reads the database: newest limit events, or limit before the cursor's (timestamp, seq, rowid); with no readable database the newest page comes from the bus's memory |
src/history/index.ts:155-192; src/history/database.ts:15-19 |
| F6 | The events table keeps the whole envelope as JSON text in envelope; indexed on (timestamp, seq) |
src/history/database.ts:29-37 |
| F7 | A gateway republishes each worker's events with workerId added to the payload |
docs/internal/EVENTS.md:228-230 |
| F8 | A worker's id, as worker.list gives it, is its instance id |
src/daemon/dispatcher.ts:901; ADR 0012 §1 |
| F9 | A worker id the gateway accepts is 1 to 128 characters | src/contract/operations.ts:917,926 |
| F10 | The tab is WorkerEventList, a plain <ul>, fed by useWorkerEvents, which reads useRecentEvents() (useEventFeed(false): the last hour, never older) and filters with eventsOfWorker |
ui/src/screens/fleet/worker-page.tsx:445-467; ui/src/state/workers.ts:185-191; ui/src/state/events.ts:55-57 |
| F11 | eventsOfWorker keeps an event when the daemon is a worker, or when workerIdOf(event) (its payload.workerId) equals the id |
ui/src/model/events.ts:142-149,185-193 |
| F12 | Activity's feed is Feed with onNearEnd={page.loadOlder}; EventFeed.loadOlder asks before=<oldest>&limit=100; Feed fills the page when it is the page's last child |
ui/src/screens/activity/index.tsx:104-147; ui/src/data/event-feed.ts:137-160; ui/src/ds/feed.css .page > .feed:last-child |
| F13 | Activity turns onNearEnd off when a chip or search narrows the list, because few held events match |
ui/src/screens/activity/index.tsx:107-109 |
| F14 | The route's tests call the app with a fake dispatch | src/http/app.test.ts:2307-2335 |
| F16 | A page from before/limit is oldest first; fewer than limit means the history ends there; a gone cursor is 422 HISTORY_NOT_KEPT, an unreadable history 503 HISTORY_UNAVAILABLE; GET /v1/events with no parameter returns the recent events in memory |
website/src/content/docs/docs/reference/events.mdx:40-47; src/history/index.ts:141-151 |
| F17 | eventsOfWorker drops worker.rejected, whose workerId is only what a refused connection claimed |
ui/src/model/events.ts:142-149; docs/internal/EVENTS.md:167 |
| F18 | The history database is node:sqlite, SQLite 3.51.2, which has json_extract and expression indexes; the schema is created in configureDatabase with CREATE … IF NOT EXISTS; reads are synchronous on the daemon's thread |
node -e "…sqlite_version(), json_extract(…)" → { v: '3.51.2', j: 1 }; src/history/database.ts:22-46; src/ports/sqlite.ts:10-14 |
| F19 | The envelope's name field is event |
src/contract/schemas.ts:972-980 |
| F20 | The console feed caps at 10,000 events and says Showing the newest 10,000 events. |
ui/src/model/events.ts:10,76-80; ui/src/data/event-feed.ts:140 |
| F21 | EventFeed also reads since=1h at start and since=<gap> after a reconnect |
ui/src/data/event-feed.ts:88-102 |
| F22 | Feed fills the page only as .page > .feed:last-child or .page > :last-child > .feed:last-child; on the worker page it sits inside the tab panel |
ui/src/ds/feed.css:13-14; ui/src/ds/choice.tsx:54-75 |
| F23 | A two-worker browser test already covers this tab and asserts No events for this worker in the last hour. |
e2e/console/workers.spec.ts:783-821,894-896 |
| F24 | A real gateway-and-worker e2e exists for the event relay | e2e/gateway-event-relay.test.ts:1-30 |
| F15 | The console docs list the worker page's tabs as Overview, Host, Warm pool; the code has Devices, Warm pool, Host, Events | website/src/content/docs/docs/worker/console.mdx:126; ui/src/model/worker-page.ts:669-673 |
Modules touched
Searches: rg "events.replay|replayPage|eventsPageQuery|eventsQuerySchema|useWorkerEvents|WorkerEventList|eventsOfWorker" over src, ui/src, e2e.
flowchart LR
UI[ui: worker Events tab, data/event-feed, ds/feed.css *] --> R[http: GET /v1/events *]
R --> C[contract: events.replay input *]
R --> D[daemon dispatcher *]
R --> GD[gateway dispatcher *]
D --> H[history: replay, replayPage *]
GD --> H
sequenceDiagram
participant T as Events tab
participant G as Gateway
participant H as History
T->>G: GET /v1/events?worker=w1&limit=100
G->>H: replayPage({limit:100, workerId:"w1"})
H-->>G: 100 envelopes with payload.workerId w1
G-->>T: 200 {events}
T->>G: GET /v1/events?worker=&limit=100
G-->>T: 400 worker must be a worker id of 1 to 128 characters
Contract and event changes
events.replay input gains workerId?: string, 1 to 128 characters. This PR bumps the protocol by one. History: after the file has opened (not inside configureDatabase, whose throws count as damage and set the file aside, src/history/index.ts:301-337,366), the history runs CREATE INDEX IF NOT EXISTS events_worker ON events ((CASE WHEN json_valid(envelope) THEN json_extract(envelope, '$.payload.workerId') END), timestamp, seq) in its own try. The json_valid guard keeps a row of bad JSON from failing the build (a bare json_extract fails it with malformed JSON). A filtered read is WHERE (CASE WHEN json_valid(envelope) THEN json_extract(envelope, '$.payload.workerId') END) = ? AND json_extract(envelope, '$.event') <> 'worker.rejected', with the same cursor and order as today; EXPLAIN QUERY PLAN gives SEARCH events USING INDEX events_worker (<expr>=?), so it walks only that worker's rows. On an existing file the index is built once. The memory fallback filters the same way before cutting to limit. The HTTP query worker: a string of 1 to 128 characters, any characters; anything else gets 400 with the message above. No event changes.
Other code on the same state
The event history (events table, the bus's memory). Searched rg "replayPage|\.replay\(" src --glob '!*.test.ts' (20 hits; 3 are leasing's unrelated requests.replay): the two dispatchers (src/daemon/dispatcher.ts:842-846, src/gateway/dispatcher.ts:374-378), the history module (src/history/index.ts:71-220), the server's handle type (src/daemon/server.ts:150) and the test fake (src/history/testing.ts:116). simlock events reaches the same path through events.replay and sends no workerId, so it is unchanged. Reads only; nothing here writes. #905 changes how the console refetches event lists; whichever lands second keeps the other's behaviour.
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
Tab → GET /v1/events?worker= |
ok | page shown, merged into the tab's feed | the console's read timeout, as today |
Tab → GET /v1/events?worker= |
422 HISTORY_NOT_KEPT |
the feed's end row says the history is gone, as Activity's | — |
Tab → GET /v1/events?worker= |
503 HISTORY_UNAVAILABLE, network error or other 5xx |
the end row offers a retry, as Activity's | — |
| History with no readable database | ok | the newest page comes from memory, filtered by workerId before it is cut to limit |
— |
| History open, existing file without the index | ok | the index is built right after the open | one pass over the table, at open |
| Index build | throws (disk full, I/O error) | logged as a warning with the error; the history stays open and keeps writing; filtered reads run without the index (a scan) until the next open retries the build. Nothing is set aside | — |
| Index build | a row of bad JSON | the row indexes as no worker; the build succeeds | — |
| Filtered page read | ok | reads at most limit rows of that worker through events_worker; an unknown worker reads none |
one index range |
No state written.
Rules in play
- ADR 0012 §1: "returns exactly one view: the worker itself" — on a worker,
workermatches its own instance id only. - ADR 0032 (event history is a SQLite database): the filter reads the existing table through one new index; no new table, column or projection.
- documentation rule 9 —
website/src/content/docs/docs/reference/events.mdx:42-47gainsworker;console.mdx:126lists the four tabs and says Events scrolls back through the worker's history.
Tests
Seams, one per line: src/http/app.test.ts (the route's refusals, fake dispatch), e2e/gateway-event-relay.test.ts (a real gateway and worker over HTTP, end to end), src/history/history.test.ts (the memory fallback), and e2e/console/workers.spec.ts:783 for the tab, extended.
- A caller sends a bad
worker: empty or 129 characters → 400worker must be a worker id of 1 to 128 characters; noworker→ as today (app.test.ts). - An operator pages a gateway's events by worker:
worker=w1&limit=100→ onlyw1's events, oldest first;before=<id>&limit=100→ the next older page;since=1h→w1's last hour; aworker.rejectedclaimingw1→ absent;worker=nobody→ none; on the worker itself, through its socket (a joined worker has no HTTP),events.replaywith its own id → every event, another id → none (gateway-event-relay.test.ts, gateway and worker real). - The history reads a worker's events without the database or the index: with no readable database,
replayPagewithworkerIdreturns only that worker's events from memory,limitcounted after the filter; an index build that throws is logged and the file is not set aside; a stored row of bad JSON does not fail the build (history.test.ts). - An operator opens a worker's Events tab on a two-worker gateway: the feed fills the page below the tabs; scrolling near the end loads that worker's older events until the start row; the other worker's events never appear; a new event of the worker arrives at the top; with none,
No events for this worker yet.(workers.spec.ts:783, extended).
Done when
pnpm test,pnpm test:e2e e2e/gateway-event-relay.test.tsandpnpm test:consolepass, with the four Tests lines above.website/src/content/docs/docs/reference/events.mdxdocumentsworker;console.mdxlists the worker page's four tabs.- The OpenAPI document lists
workeronGET /v1/events(it is built from the declaration).
Out of scope
- A
--workerflag onsimlock events; a filter on the MCP tools. - Changing the console's event cap.
- Activity's own filters.
Depends on
- The console reads per page through a query cache, and events mark data stale #914 (rewrites
useWorkerEvents's reads,ui/src/state/workers.ts:185-191) - The daemon says what needs the operator: GET /v1/inbox #915 (bumps the protocol; it already follows One lease is read directly, and gateway leases name the worker's lease id #913)
- One shared recent-events list per console tab #917 (splits
ui/src/data/event-feed.tsinto query functions and sets the cap)
This task's console half is built on what #914 and #917 leave: the Tests lines say what the tab does, not which class does it.
Written by an agent.
Activity
feat(ui): one shared recent-events list per console tab (#939)
test: GET /v1/events refuses a bad worker and passes a good one to ev…
test: the history reads a worker's events without the database or the…
feat(ui): the worker's Events tab scrolls back through that worker's …
docs: the events route takes worker, the console lists the worker pag…
test(history): bound a worker id at the operation, and show a second …
fix(ui): a worker's Events tab filters by the daemon mode as it is wh…
feat(history): the worker's Events tab scrolls back through its histo…