Repository navigation
The daemon says what needs the operator: GET /v1/inbox #915
Description
Part of #905.
Scope
The daemon decides what needs the operator: GET /v1/inbox returns the Inbox items, in both modes. The sidebar count reads only it, on every page. The Inbox page and the Overview's Needs you read it plus /v1/workers (for worker and device names, links and lease counts), and no longer read leases, tokens or waiting requests. The rules move from the console to src/contract/inbox.ts in this task, so they live in one place.
In short
Same Inbox, same count, same buttons. The sidebar count, on every page, stops reading the four fleet lists; the Inbox page and Needs you keep only the worker list. Each item carries a small copy of what it is about, e.g.
{ "key": "w1/dev_9/quarantined", "group": "Broken", "condition": "quarantined",
"device": { "id": "dev_9", "state": "quarantined", "workerId": "w1", "workerLabel": "mac-07", … } }flowchart LR
S[sidebar count / Inbox / Needs you] --> Q["['inbox'] (new)"] --> R["GET /v1/inbox (new)"] --> D["inbox.get → src/contract/inbox.ts (moved)"]
The Inbox page's "could not check" note goes: the daemon reads its own data, and a failed GET /v1/inbox shows the page's refusal instead.
If it goes wrong: the count or the list differs from what the Fleet and Leases pages show.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | inboxItems(workers, {now, requests, leases, tokens}) builds worker, device, waiting and ending items; keys <worker>/<condition>, <worker>/<device>/<condition>, request/<id>/waiting, lease/<id>/ending. |
ui/src/model/inbox.ts:62-150 |
| F2 | Thresholds: waiting > 60 s (WAITING_TOO_LONG_MS); ending when ttlDeadline - now < 5 min (EXPIRING_UNDER_MS); RAM from the daemon's overLimit. |
ui/src/model/inbox.ts:60, 87; ui/src/model/leases.ts:138-141; src/contract/schemas.ts:509-517 |
| F3 | Groups: ending → Heads-up; error tone (disconnected, incompatible, quarantined) → Broken; else Needs a look. problemCount counts items not Heads-up. |
ui/src/model/inbox.ts:153-175 |
| F4 | Helpers the rules use: holderName, deviceOfLease, isExpiring (model/leases.ts:64, 102, 140), stateEnteredAt (model/workers.ts:57), requestedDevice (model/waiting.ts), and the helpers they call: holderOf, agentIdOf, workerOfLease (model/leases.ts:44-78), platformName (model/workers.ts:26). |
as cited |
| F5 | Callers: useInboxCount (state/shell.ts:12-19), useInbox (state/inbox.ts:87-150), overviewOf (model/overview.ts:187, 224-232), the Activity drawer's link (model/event-list.ts:156). |
as cited |
| F6 | Row buttons need the worker's connection, drained, update facts, and for the Update… confirm the count of leased devices (updateFactsOf counts worker.devices); a waiting request's spec, id, requesterId, workerId; an ending lease's row, built with leaseRows({leases, tokens, workers}), which needs the worker list for the Release confirm's worker name and the toast's device link. |
ui/src/model/actions.ts:62, 81-83, 171-172, 290-345; ui/src/model/versions.ts:16; ui/src/model/leases.ts:179-196 |
| F7 | The four input types are contract types. | ui/src/model/leases.ts:10, 16; workers.ts:5; waiting.ts:9 |
| F8 | A worker builds its own worker view in #workerList; a gateway answers from workers.views(). Both dispatchers serve lease.list, list.get {kind:"requests"} and token.list. |
src/daemon/dispatcher.ts:870-910, 627, 812-820, 1044; src/gateway/dispatcher.ts:244-255, 413, 517, 545-578 |
| F9 | src/contract/ imports nothing outside itself; the console may import contract values only from version-order.ts, by path (maintainer-authorized, #825). |
src/contract/AGENTS.md:5-6; .dependency-cruiser.mjs:31; ui/AGENTS.md Uses |
| F10 | A read op is added as usage.get was: contract op, both dispatchers, a declared route. |
commit 60521f0f4; src/http/route-declarations.ts:73; src/http/app.ts:717 |
| F11 | The rules' tests. | ui/src/model/inbox.test.ts, ui/src/state/inbox.test.ts |
| F12 | The Inbox drawer's event lines name the worker and the device from the worker list. | ui/src/model/inbox.ts:472-483; ui/src/model/activity.ts:17-41 |
| F13 | Console browser specs fill the Inbox, Needs you and the count by stubbing /v1/lease-requests and /v1/leases; two expect a "Could not check" note. |
e2e/console/inbox.spec.ts:211-290, 371, 602-650; overview.spec.ts:364-468; shell.spec.ts:270, 292; views.spec.ts:289 |
| F14 | The Inbox screen shows couldNotCheck; the Overview takes it from useInboxReads. |
ui/src/screens/inbox/index.tsx:96-102; ui/src/state/overview.ts:12 |
Modules touched
src/contract/inbox.ts (new: the rules of F1–F3 and the helpers of F4, zod-free), src/contract/operations.ts + schemas.ts (inbox.get), src/contract/protocol.ts (bump), src/contract/index.ts, src/daemon/dispatcher.ts, src/gateway/dispatcher.ts, src/http/admin-declarations.ts + app.ts (GET /v1/inbox), .dependency-cruiser.mjs (the console may import src/contract/inbox.ts by path, as version-order.ts), ui/src/model/inbox.ts (rules removed; rows, tones, filters stay), ui/src/model/leases.ts, workers.ts, waiting.ts (helpers imported from src/contract/inbox.ts), ui/src/model/actions.ts (inboxActions from the subject), ui/src/model/overview.ts, ui/src/model/event-list.ts (the Activity drawer's "Open Inbox item" link imports inboxItems from src/contract/inbox.ts; no extra read), ui/src/state/inbox.ts, shell.ts, overview.ts, queries.ts (['inbox'], poll 10 s) and ui/src/model/stale-keys.ts (inbox in the Event map rows), ui/src/screens/inbox/index.tsx and the Overview screen (the "Could not check" note goes, F14), the console browser specs of F13 (they stub GET /v1/inbox with items in the new shape instead of the lists), tests beside each, ui/AGENTS.md, src/contract/AGENTS.md, src/http/AGENTS.md, website/.../gateway/fleet-console.mdx (Attention section, if it names how the count is found). Every helper of F4 moves once and the console imports it from there. Searches: inboxItems, problemCount, holderName, holderOf, agentIdOf, workerOfLease, platformName, deviceOfLease, stateEnteredAt, isExpiring, requestedDevice, couldNotCheck, useInboxReads.
sequenceDiagram
participant C as Console
participant H as HTTP app
participant D as Dispatcher
C->>H: GET /v1/inbox (operator token)
H->>D: inbox.get
D->>D: worker views, leases, waiting requests, tokens → inboxItems(now)
D-->>H: {items}
H-->>C: 200
Note over C,H: agent token → 403 FORBIDDEN
Contract and event changes
- New op
inbox.get, roleadmin, effectread, input{}, output{ items: InboxItem[] }with the item shape of The console stays smooth and light on a big fleet #905 Shared valuesinbox.get, plusleasedDevices(non-negative integer) on the worker subject for the Update… confirm andstateEnteredAt(optional epoch ms) on the device subject.heldLeasesis a non-negative integer; every other field reuses its existing schema (worker view fields, device,statusLeaseSchema, waiting request). - New route
GET /v1/inbox, tag admin, no query. - This PR bumps the protocol by one. No event changes.
Other code on the same state
Reads the same worker views, lease index, request book and tokens table as worker.list, lease.list, list.get and token.list (F8), never writes. An item computed while a view refreshes shows that view as of the read, as those routes do. Searches: views() (gateway, 9 hits), registry.snapshot (daemon, 14 hits).
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
inbox.get |
ok | items | none |
inbox.get |
token store unavailable | holder falls back to the requester id, as the console does with no tokens today | — |
GET /v1/inbox |
agent token | 403 FORBIDDEN |
— |
| console read | refused | as every query (#914): last items kept, message shown; the page's refusal replaces the "Could not check" note | — |
| console read | lost | as every query (#914): data kept, no error | — |
No state is written.
Rules in play
- Architecture rule 10 (one rule, one place): the console copy of the rules and helpers is deleted; both sides import
src/contract/inbox.ts. - ADR 0012 "a worker answers the fleet operations as a fleet of one":
inbox.geton a worker uses its own worker view. - ADR 0044 §3: complies. Toolchain protocol bump: bumps.
Tests
Seams: the rules in src/contract/inbox.test.ts, moved from ui/src/model/inbox.test.ts; the route through src/http/app.test.ts; the console through the query layer and renderToStaticMarkup, as ui/src/screens/inbox/index.test.tsx.
- The inbox rules turn fleet data into items: each worker (disconnected, incompatible, drained, update failed, over RAM), device (quarantined, stalled, absent), waiting (59 s no, 61 s yes) and ending (4 min yes, 6 min no) condition; group, order and key of each; each subject's fields,
heldLeasesincluded. - An admin reads
GET /v1/inbox: gateway with two workers; worker (fleet of one); agent token → 403. - The sidebar count, Inbox and Needs you show the server's items: count leaves out Heads-up; the keys observed on Usage include no
workers,leases,tokensorlease-requests, and on Inbox and Overview's Needs you noleases,tokensorlease-requests(Needs you means the panel's own reads; the Overview's Waiting and Ending soon tiles keep readinglease-requestsandleases) (decided in delivery, accepted by the maintainer 2026-10-23); a drained item's fix button is Undrain and an update-failed one's is Update… with the leased-device count in its confirm; an ending item's menu is Release with the worker's name; drawer facts (time in state, last seen, held leases) and event lines (worker and device names) as today.
Done when
pnpm vitest run src/contract src/http ui/srcpasses;ui/src/model/inbox.tshas noinboxItems.- The boundaries and protocol bump checks pass.
- The console browser lane passes, with the specs of F13 stubbing
GET /v1/inboxand the "Could not check" cases removed.
Out of scope
Paging the items; new conditions; the Inbox row's layout (#912).
Depends on
- One lease is read directly, and gateway leases name the worker's lease id #913 (both bump the protocol)
- The console reads per page through a query cache, and events mark data stale #914
Not on #912 (maintainer, 2026-10-23): the two only overlap in inboxActions, state/inbox.ts and the Inbox screen, and waiting on it made a loop with #906 (#912 merges with #906's stack, whose #911 needs this task). The feature that merges second resolves the overlap.
Written by an agent.
Activity
[-]The console reads per page through a query cache, and events mark data stale[/-][+]The daemon says what needs the operator: GET /v1/inbox[/+]test: the inbox rules turn fleet data into items (#915)
feat(contract): the inbox rules and their helpers live in one place (#…
test: inbox.get reads the fleet's leases, waiting requests and tokens (…
feat(gateway): inbox.get reads the fleet's leases, waiting requests a…
test: inbox.get on a worker answers a lease about to end and a reques…
feat(daemon): inbox.get on a worker reads its own view, leases and qu…
test: GET /v1/inbox dispatches inbox.get for the caller and answers i…
feat(http): GET /v1/inbox (#915)
test: GET /v1/inbox on a worker answers an operator and refuses an ag…
test: the sidebar count, Inbox and Needs you show the server's items (#…
test: the console specs stub GET /v1/inbox (#915)
docs: the Inbox is the daemon's, GET /v1/inbox (#915)
test: the console specs stub GET /v1/inbox (#915)
fix(daemon): the socket answers inbox.get (#915)
test: the audit proves the Inbox rules' ties, holders, token store an…
fix: the daemon manual names protocol 59, and a test pins that Needs …
feat(inbox): the daemon says what needs the operator: GET /v1/inbox (#…
test(console): an idle console on a 50-worker fleet stays under 40 re…