Skip to content

Console: the worker's Events tab scrolls back through its history #911

Closed

Description

@V3RON

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
Loading

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
Loading
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
Loading

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, worker matches 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-47 gains worker; console.mdx:126 lists 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 → 400 worker must be a worker id of 1 to 128 characters; no worker → as today (app.test.ts).
  • An operator pages a gateway's events by worker: worker=w1&limit=100 → only w1's events, oldest first; before=<id>&limit=100 → the next older page; since=1h → w1's last hour; a worker.rejected claiming w1 → absent; worker=nobody → none; on the worker itself, through its socket (a joined worker has no HTTP), events.replay with 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, replayPage with workerId returns only that worker's events from memory, limit counted 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.ts and pnpm test:console pass, with the four Tests lines above.
  • website/src/content/docs/docs/reference/events.mdx documents worker; console.mdx lists the worker page's four tabs.
  • The OpenAPI document lists worker on GET /v1/events (it is built from the declaration).

Out of scope

  • A --worker flag on simlock events; a filter on the MCP tools.
  • Changing the console's event cap.
  • Activity's own filters.

Depends on

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

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  3. self-assigned this
    on Oct 23, 2026
  4. added a commit that references this issue on Oct 23, 2026
    3080eca
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions