Skip to content

The console reads per page through a query cache, and events mark data stale #914

Closed

Description

@V3RON

Part of #905.

Scope

The console reads each route only while a page that shows it is on screen, at that route's own interval, and an event marks only the data it changes as stale, read once per burst. Leaving a page stops its reads; its data is dropped a minute later. Nothing the operator sees on a page changes.

Kept whole although it touches about 20 files: the cache, the event map and the change of every useLiveResource caller are one switch; a half switch would run two read loops at once.

In short

An idle Leases page stops asking for four lists every second. It reads leases every 10 s, workers every 15 s, and after a lease event once, 300 ms after the burst ends (at most 1 s after it starts), and once more 1.5 s later. A change with an event still shows within about a second; host load shows within 15 s.

flowchart LR
  P[page hook] --> Q["query cache (new, ui/src/state)"]
  Q --> A[ApiClient.get]
  S[event stream] --> M["event map + burst window (new)"]
  M -->|mark stale| Q
  C[connection: connected / lost / hidden] --> Q
Loading

Before → after, an idle Leases page for one minute (no events): about 240 requests → about 22 (leases 6, workers 4, and the sidebar's waiting requests 12; tokens once) until #915 moves the sidebar to the inbox.

If it goes wrong: a change waits up to 15 s, or a hidden tab keeps reading.

Technical spec

Facts

# Fact Source
F1 Every watched route is read every 1 s and after every stream event; one request in flight per route, one queued. ui/src/data/live/connection.ts:5, 175-185, 260-268, 385
F2 watch reads at once on a route's first listener; routes stay in the map forever. ui/src/data/live/connection.ts:108-112, 167-173
F3 A read failure is classified: refusal (ApiError, SyntaxError) keeps data and stores the error; a timeout while starting retries; anything else loses the connection. Each answer's Date header corrects the clock offset; the first answer while starting marks it connected. ui/src/data/live/connection.ts:70-72, 196-240
F4 Hidden tab: polling stops, the stream closes; shown again: reads the screen once and reopens. Lost: health checks 1, 2, 4, 8 s then every 10 s, then reads the screen once. ui/src/data/live/connection.ts:404-454
F5 useLiveResource is the only route read hook: 29 calls in ui/src/state/. ui/src/state/live.tsx:68-76; grep -n useLiveResource ui/src/state/*.ts | grep -v import
F6 @tanstack/* is forbidden in ui/src/data/ and ui/src/model/, allowed in ui/src/state/. .dependency-cruiser.mjs:18, 240-262
F7 @tanstack/react-query is not a dependency. package.json:89-90
F8 A new dependency nothing imports statically gets its .fallowrc.json entry. docs/internal/agent-rules/always-in-scope.md:53-56
F9 LiveProvider wraps Shell once signed in. ui/src/app/app.tsx:43-45
F10 watchMode reads /v1/status on each stream open and every 10 min, outside the route map. ui/src/data/mode.ts:53-57, 89
F11 Public docs: "a lease being granted or a device booting shows within about a second, with no reload." website/src/content/docs/docs/worker/console.mdx:94
F12 e2e helpers assume "the page re-reads the list every second". e2e/console/workers-answers.ts:12-14, used by workers.spec.ts, workers-gateway-stops-mid-fetch.spec.ts
F13 Event names and payload ids. src/bus/index.ts:12-400
F14 Tests drive LiveConnection with a fake API and clock: "the screen is refetched every second…", "an event refetches the routes…", "a burst of events … exactly one more request". ui/src/data/live/connection.test.ts:18, 33, 53; ui/src/data/live/test-support.ts
F15 The 1 s poll is what asks again for a stream the daemon refused ("the next poll asks for one again"). ui/src/data/live/connection.ts:264-266, 324-327; connection.test.ts:462
F16 A read lost to the network stores nothing today: the page keeps its data, only the sidebar says Reconnecting; loadView turns any stored error into a refusal message. ui/src/data/live/connection.ts:224-226; ui/src/state/resource.ts:11-27
F17 22 lines of connection.test.ts drive reads through connection.watch. grep -n "connection.watch" ui/src/data/live/connection.test.ts

Modules touched

package.json, pnpm-lock.yaml, .fallowrc.json (if needed); ui/src/data/live/connection.ts (drops the route map and poll; keeps the stream, phase, health checks and clock offset; adds read(path), which does F3's classification for one GET); ui/src/state/queries.ts (new: QueryClient, keys and times from #905 Shared values, useRouteQuery returning today's ResourceState); ui/src/model/stale-keys.ts (new, pure: event → keys, #905 Event map; the ['inbox'] key is added by #915); ui/src/state/live.tsx (provider holds the client; useLiveResource → useRouteQuery); every ui/src/state/*.ts that calls useLiveResource (F5); tests in ui/src/data/live/connection.test.ts and new ui/src/state/queries.test.ts; e2e/console/workers-answers.ts and the specs that rely on it (F12); ui/AGENTS.md (Layers, a "Reads" section), console.mdx:94 (F11). Searches: useLiveResource, connection.watch, connection.resource, refetchScreen.

Contract and event changes

None (searched src/contract/ and src/bus/: no change). Reads the event names of F13 only.

Other code on the same state

  • EventFeed (ui/src/data/event-feed.ts) keeps listening to the stream as today; One shared recent-events list per console tab #917 moves it.
  • watchMode (F10) keeps its own read; it is not a query key.
  • state/search.ts useSettledOnce reads through useRouteQuery; it keeps the first settled answer as today.
  • Actions (state/actions.ts) send writes and rely on the events that follow, as today.
    Searches: onEvent(, onStreamOpened(, api.get in ui/src/ (8 hits).

Failure modes

Call Outcome What happens Limit, counted from
query read ok data replaced (structurally shared) 10 s request timeout, from send (today's)
query read refusal (4xx/5xx with a code, 501 unsupported) error stored, last data kept, no retry until the next poll or event —
query read timeout while starting read again at once (F3) 10 s per try
query read network failure or timeout while connected connection lost; the key keeps its state from before the read (data, or loading), never an error (F16); every query paused; reads resume once the health check answers, one per key on screen F4's back-off
stream refused asked for again 1 s later, and every 1 s while refused, as today (F15) 1 s from the refusal
stream lost / hidden as F4; on return each key on screen is read once —

No stored state; the cache lives in the tab.

Rules in play

  • ADR 0044 §1, §2: complies (keys, times, event map, burst window as The console stays smooth and light on a big fleet #905 Shared values).
  • ADR 0013 §2, §4, §5 still bind: one stream read with fetch, browser-side ticking, the lost/health-check cycle.
  • ui/AGENTS.md "A screen never fetches": screens unchanged; hooks keep their view models.
  • Documentation rule 9: console.mdx:94 gains "Readings with no event of their own, such as host load, refresh within 15 seconds."

Tests

Seam: the query layer in ui/src/state/queries.ts through QueryObservers in Node, with the fake API and clock of ui/src/data/live/test-support.ts, modelled on connection.test.ts; that file's 1 s and refetch-on-event tests (F14) are replaced by the lines below, and every other test that reads through connection.watch (F17: hidden tab, health-check reconnect, refused read, starting daemon, refused stream, data age, clock offset) moves to queries.test.ts with its title and cases.

  • A page on screen polls its keys; leaving or hiding stops it: each key reads at its interval (lease-requests 5 s, leases and one lease 10 s, workers 15 s, stats 60 s, tokens never); last observer gone → no more reads; hidden tab → none, shown → one read per observed key; disconnected → none; reconnected → one read per observed key.
  • Data no page uses is dropped: observer back within 60 s → kept data at once, a read only if no longer fresh (2 s; stats 30 s; tokens 5 min); after 60 s → loading, then a read.
  • Events mark keys stale per the map, in bursts: one case per Event map row (each marks exactly its keys; device.* with leaseId marks every ['lease', id]); 50 events within 200 ms → one read per observed key 300 ms after the last, one more 1.5 s later; an event every 200 ms → first read 1 s after the first event; a stale key with no observer → no read until observed; an unknown name → nothing.
  • A read's failure is handled as today (F3, F16): a refusal keeps the last data and shows its message; a timeout while starting reads again; a network failure loses the connection and the page shows its data, or loading, with no error; a refused stream is asked for again 1 s later.

Done when

  • pnpm vitest run ui/src passes, and connection.test.ts has no 1 s poll test left.
  • pnpm lint passes (dependency-cruiser: no @tanstack/* under ui/src/data or ui/src/model).
  • The console browser lane passes (e2e/console).

Out of scope

ETags (#916), renewals (#916), the Inbox route (#915), event lists and the clock (#917).

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  2. changed the title [-]One lease is read directly, and gateway leases name the worker's lease id[/-] [+]The console reads per page through a query cache, and events mark data stale[/+] on Oct 23, 2026
  3. 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
  4. self-assigned this
    on Oct 23, 2026
  5. added 2 commits that reference this issue on Oct 23, 2026
    bf3ebee
    ab4698e
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