From b48219001e40bda40ea04fb1bfad73962aca1d82 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:20:12 +0200 Subject: [PATCH 01/18] test: inbox.get answers the items the fleet's views, leases, waiting requests and tokens make (#915) --- src/contract/AGENTS.md | 2 +- src/contract/inbox.ts | 28 +++++++++++++++ src/contract/operations.ts | 11 ++++++ src/contract/protocol.ts | 2 +- src/contract/schemas.ts | 59 +++++++++++++++++++++++++++++++ src/daemon-protocol/index.test.ts | 2 +- src/daemon/dispatcher.ts | 3 ++ src/gateway/dispatcher.test.ts | 25 +++++++++++++ src/gateway/dispatcher.ts | 3 ++ 9 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 src/contract/inbox.ts diff --git a/src/contract/AGENTS.md b/src/contract/AGENTS.md index 6123d9d19..675d052ca 100644 --- a/src/contract/AGENTS.md +++ b/src/contract/AGENTS.md @@ -56,7 +56,7 @@ Three identities: ## Protocol version -- `PROTOCOL_VERSION_RANGE` in `protocol.ts` holds it, now `{ min: 58, max: 58 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`. +- `PROTOCOL_VERSION_RANGE` in `protocol.ts` holds it, now `{ min: 59, max: 59 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`. - Every change to a contract shape bumps it by one in the same PR. Move `min` too, unless a compatibility path stays side by side. `.agents/scripts/protocol-bump-check.mjs` checks it on push and in CI; it counts every file here as a shape but tests, `index.ts` and the diff --git a/src/contract/inbox.ts b/src/contract/inbox.ts new file mode 100644 index 000000000..e55844cbe --- /dev/null +++ b/src/contract/inbox.ts @@ -0,0 +1,28 @@ +import type { + inboxItemSchema, + statusLeaseSchema, + tokenRecordSchema, + waitingRequestSchema, + workerViewSchema, +} from "./schemas.js"; +import type { z } from "zod"; + +export type InboxItem = z.infer; +export type WorkerView = z.infer; +export type LeaseRecord = z.infer; +export type TokenRecord = z.infer; +export type WaitingRequest = z.infer; + +export interface InboxContext { + readonly now: number; + readonly requests?: readonly WaitingRequest[] | undefined; + readonly leases?: readonly LeaseRecord[] | undefined; + readonly tokens?: readonly TokenRecord[] | undefined; +} + +export function inboxItems( + _workers: readonly WorkerView[], + _context?: InboxContext, +): readonly InboxItem[] { + return []; +} diff --git a/src/contract/operations.ts b/src/contract/operations.ts index 28c24fba1..afeb98edd 100644 --- a/src/contract/operations.ts +++ b/src/contract/operations.ts @@ -52,6 +52,7 @@ import { tokenRoleSchema, usageOutputSchema, usageWindowSchema, + inboxItemSchema, waitingRequestSchema, capabilityListSchema, workerViewSchema, @@ -1111,6 +1112,15 @@ export const alertsTest = defineOperation({ output: z.object({ destination: z.string(), status: z.number() }), }); +// fallow-ignore-next-line unused-export -- consumed only through the OPERATIONS registry, not by name; still public contract surface. +export const inboxGet = defineOperation({ + name: "inbox.get", + role: "admin", + effect: "read", + input: z.object({}), + output: z.object({ items: z.array(inboxItemSchema) }), +}); + const identityGet = defineOperation({ name: "identity.get", role: "agent", @@ -1149,6 +1159,7 @@ export const OPERATIONS = { "events.subscribe": eventsSubscribe, "events.unsubscribe": eventsUnsubscribe, "usage.get": usageGet, + "inbox.get": inboxGet, "token.create": tokenCreate, "token.list": tokenList, "token.revoke": tokenRevoke, diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index 309942908..45f8bc6ca 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -10,7 +10,7 @@ export interface ProtocolRange { const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().int() }); /** Widen only once a second version is kept alive side by side with the first; a bump that leaves no shim behind moves both ends. */ -export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 58, max: 58 }; +export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 59, max: 59 }; const LEGACY_DAEMON_PROTOCOL_VERSION = 2; diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index b65392a5c..4ea3f3310 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -1387,6 +1387,65 @@ export const statusLeaseSchema = leaseRecordSchema.extend({ workerLeaseId: z.string().optional(), }); +export const inboxGroupSchema = z.enum(["Broken", "Needs a look", "Heads-up"]); + +// why: an Inbox item names the worker it is about by the view's fields that the page does not read from the worker list, plus two counts the view's lists would otherwise have to be sent for. +export const inboxWorkerSchema = workerViewSchema + .omit({ catalog: true, devices: true, downloads: true, leases: true }) + .extend({ + heldLeases: z.number().int().nonnegative(), + leasedDevices: z.number().int().nonnegative(), + }); + +export const inboxDeviceSchema = statusDeviceSchema.and( + z.object({ + stateEnteredAt: z.number().optional(), + workerId: z.string(), + workerLabel: z.string().optional(), + }), +); + +export const inboxLeaseSchema = statusLeaseSchema.extend({ + holder: z.string(), + model: z.string(), +}); + +const inboxItemBase = { group: inboxGroupSchema, key: z.string() }; + +/** + * One thing that needs the operator (`inbox.get`): the rules in `inbox.ts` decide it. Each item + * carries a small copy of the one subject it is about. + */ +export const inboxItemSchema = z.union([ + z.object({ + ...inboxItemBase, + condition: z.enum(["disconnected", "incompatible", "drained", "update failed"]), + worker: inboxWorkerSchema, + }), + z.object({ + ...inboxItemBase, + condition: z.literal("over RAM budget"), + limitBytes: z.number(), + usedBytes: z.number(), + worker: inboxWorkerSchema, + }), + z.object({ + ...inboxItemBase, + condition: z.enum(["quarantined", "stalled", "absent"]), + device: inboxDeviceSchema, + }), + z.object({ + ...inboxItemBase, + condition: z.literal("waiting"), + request: waitingRequestSchema, + }), + z.object({ + ...inboxItemBase, + condition: z.literal("ending"), + lease: inboxLeaseSchema, + }), +]); + const USAGE_MAX_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; /** `usage.get`'s input: wire input, so both ends are bounded before anything is read (safety rule 10). */ diff --git a/src/daemon-protocol/index.test.ts b/src/daemon-protocol/index.test.ts index f7e37cb58..555d77724 100644 --- a/src/daemon-protocol/index.test.ts +++ b/src/daemon-protocol/index.test.ts @@ -9,7 +9,7 @@ import { describe("daemon protocol", () => { it("keeps the current protocol version and newline framing", () => { - expect(DAEMON_PROTOCOL_VERSION).toBe(58); + expect(DAEMON_PROTOCOL_VERSION).toBe(59); expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n'); }); diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index 79837b72b..c6bb57e21 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -321,6 +321,7 @@ export class Dispatcher { "events.unsubscribe": this.#eventsUnsubscribe, "identity.get": identityGet, "token.create": this.#tokenCreate, + "inbox.get": this.#inboxGet, "token.list": this.#tokenList, "token.revoke": this.#tokenRevoke, "component.install": this.#componentInstall, @@ -1052,6 +1053,8 @@ export class Dispatcher { return { secret, token: record }; }; + #inboxGet: Handler<"inbox.get"> = () => ({ items: [] }); + #tokenList: Handler<"token.list"> = async () => ({ tokens: await this.#requireTokens().list() }); #tokenRevoke: Handler<"token.revoke"> = async (input) => ({ diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index e2c1f42cb..8b3e20169 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -960,6 +960,31 @@ describe("GatewayDispatcher", () => { expect(Object.keys(views[0] ?? {})).not.toContain("leases"); }); + it("inbox.get answers the items the fleet's views, leases, waiting requests and tokens make", async () => { + const { dispatcher, workers } = harness(); + workers.connected("wrk_1", "mac-mini-1", "0.3.0"); + workers.connected("wrk_2", "mac-mini-2", "0.3.0"); + workers.disconnected("wrk_2"); + + const answer = await dispatcher.dispatch("inbox.get", {}, session()); + + expect(answer).toEqual({ + items: [ + { + condition: "disconnected", + group: "Broken", + key: "wrk_2/disconnected", + worker: expect.objectContaining({ + connection: "disconnected", + heldLeases: 0, + id: "wrk_2", + leasedDevices: 0, + }), + }, + ], + }); + }); + it("lists each of an owner's gateway-issued leases under its own record, and none whose worker has no view", async () => { const { dispatcher, leaseIndex, workers } = harness(); workers.connected("wrk_1", "mac-mini-1", undefined); diff --git a/src/gateway/dispatcher.ts b/src/gateway/dispatcher.ts index 7f2a361f1..a1568e7c3 100644 --- a/src/gateway/dispatcher.ts +++ b/src/gateway/dispatcher.ts @@ -241,6 +241,7 @@ export class GatewayDispatcher { "events.subscribe": this.#eventsSubscribe, "events.unsubscribe": this.#eventsUnsubscribe, "token.create": this.#tokenCreate, + "inbox.get": this.#inboxGet, "token.list": this.#tokenList, "token.revoke": this.#tokenRevoke, "worker.list": this.#workerList, @@ -411,6 +412,8 @@ export class GatewayDispatcher { return { revoked }; }; + #inboxGet: Handler<"inbox.get"> = () => ({ items: [] }); + #workerList: Handler<"worker.list"> = () => ({ workers: this.options.workers.views() }); #workerDrain: Handler<"worker.drain"> = async (input) => { From 3081b5964557aad1608c045d227f66a89e48f2f6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:21:35 +0200 Subject: [PATCH 02/18] feat(gateway): inbox.get answers a gateway's worker items (#915) --- src/contract/inbox.ts | 53 +++++++++++++++++++++++++++++---- src/contract/index.ts | 1 + src/contract/operations.test.ts | 2 ++ src/gateway/dispatcher.ts | 6 +++- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/contract/inbox.ts b/src/contract/inbox.ts index e55844cbe..94ada542a 100644 --- a/src/contract/inbox.ts +++ b/src/contract/inbox.ts @@ -8,10 +8,10 @@ import type { import type { z } from "zod"; export type InboxItem = z.infer; -export type WorkerView = z.infer; -export type LeaseRecord = z.infer; -export type TokenRecord = z.infer; -export type WaitingRequest = z.infer; +type WorkerView = z.infer; +type LeaseRecord = z.infer; +type TokenRecord = z.infer; +type WaitingRequest = z.infer; export interface InboxContext { readonly now: number; @@ -20,9 +20,50 @@ export interface InboxContext { readonly tokens?: readonly TokenRecord[] | undefined; } +type InboxGroup = InboxItem["group"]; +type WorkerItem = Extract; + +// why: a lost or incompatible worker is broken, which is the error tone the console shows; every +// other worker condition needs a look. +function groupOf(condition: InboxItem["condition"]): InboxGroup { + if (condition === "ending") return "Heads-up"; + return condition === "disconnected" || condition === "incompatible" || condition === "quarantined" + ? "Broken" + : "Needs a look"; +} + +function workerSubject(worker: WorkerView): WorkerItem["worker"] { + const { catalog: _catalog, devices, downloads: _downloads, leases, ...rest } = worker; + return { + ...rest, + heldLeases: leases?.length ?? 0, + leasedDevices: (devices ?? []).filter((device) => device.state === "leased").length, + }; +} + +// why: a worker that is both disconnected and drained is two items, since each clears on its own. export function inboxItems( - _workers: readonly WorkerView[], + workers: readonly WorkerView[], _context?: InboxContext, ): readonly InboxItem[] { - return []; + return workers.flatMap(workerItems); +} + +function workerItems(worker: WorkerView): InboxItem[] { + const items: InboxItem[] = []; + const subject = workerSubject(worker); + const add = ( + condition: "disconnected" | "incompatible" | "drained" | "update failed", + key: string, + ) => + items.push({ + condition, + group: groupOf(condition), + key: `${worker.id}/${key}`, + worker: subject, + }); + if (worker.connection === "disconnected" || worker.connection === "incompatible") { + add(worker.connection, worker.connection); + } + return items; } diff --git a/src/contract/index.ts b/src/contract/index.ts index bf419020f..bc2d913ea 100644 --- a/src/contract/index.ts +++ b/src/contract/index.ts @@ -9,6 +9,7 @@ export * from "./relay.js"; export * from "./roles.js"; export * from "./schemas.js"; export * from "./events-page.js"; +export * from "./inbox.js"; export * from "./usage-window.js"; export * from "./version-order.js"; export * from "./worker-view.js"; diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index bc082ecd8..5a941e0e1 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -45,6 +45,7 @@ const ROLE_MATRIX: ReadonlyArray<{ { name: "events.subscribe", input: {}, role: "admin" }, { name: "events.unsubscribe", input: {}, role: "admin" }, { name: "usage.get", input: { from: 0, to: 60_000 }, role: "admin" }, + { name: "inbox.get", input: {}, role: "admin" }, { name: "token.create", input: { role: "agent" }, role: "admin" }, { name: "driver.passthrough", input: { args: ["devices"], tool: "adb" }, role: "agent" }, { @@ -135,6 +136,7 @@ const EFFECT_MATRIX: ReadonlyArray<{ { name: "events.subscribe", input: {}, effect: "read" }, { name: "events.unsubscribe", input: {}, effect: "read" }, { name: "usage.get", input: { from: 0, to: 60_000 }, effect: "read" }, + { name: "inbox.get", input: {}, effect: "read" }, { name: "token.create", input: { role: "agent" }, effect: "write" }, { name: "driver.passthrough", input: { args: ["devices"], tool: "adb" }, effect: "read" }, { diff --git a/src/gateway/dispatcher.ts b/src/gateway/dispatcher.ts index a1568e7c3..39448ce01 100644 --- a/src/gateway/dispatcher.ts +++ b/src/gateway/dispatcher.ts @@ -39,6 +39,7 @@ import { createUsageReader, type UsageReader } from "../core/index.js"; import type { EventHistoryHandle } from "../history/index.js"; import { consoleUrlField, + inboxItems, isSimlockError, EVENTS_PAGE_DEFAULT, OPERATIONS, @@ -412,7 +413,10 @@ export class GatewayDispatcher { return { revoked }; }; - #inboxGet: Handler<"inbox.get"> = () => ({ items: [] }); + // why: the rules read the same views, as `worker.list` serves them. + #inboxGet: Handler<"inbox.get"> = () => ({ + items: [...inboxItems(this.options.workers.views())], + }); #workerList: Handler<"worker.list"> = () => ({ workers: this.options.workers.views() }); From c42c4a6128b2b5ee47574791f962893689bf7fe5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:23:01 +0200 Subject: [PATCH 03/18] test: the inbox rules turn fleet data into items (#915) --- src/contract/inbox.test.ts | 186 +++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 src/contract/inbox.test.ts diff --git a/src/contract/inbox.test.ts b/src/contract/inbox.test.ts new file mode 100644 index 000000000..1b0bb7b88 --- /dev/null +++ b/src/contract/inbox.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import type { z } from "zod"; + +import { inboxItems } from "./inbox.js"; +import type { + statusDeviceSchema, + statusLeaseSchema, + tokenRecordSchema, + waitingRequestSchema, + workerViewSchema, +} from "./schemas.js"; + +type WorkerView = z.infer; +type Device = z.infer; +type Lease = z.infer; +type Token = z.infer; +type Waiting = z.infer; + +const NOW = Date.parse("2026-10-02T12:00:00Z"); +const GIB = 1024 ** 3; + +function worker(id: string, overrides: Partial = {}): WorkerView { + return { connection: "connected", drained: false, id, lastSeenAt: NOW, ...overrides }; +} + +function device(id: string, overrides: Partial = {}): Device { + return { + id, + physical: false, + servesDefaultMode: true, + spec: { model: "iPhone 16", osVersion: "18.4", physical: false, platform: "ios" }, + state: "ready", + ...overrides, + }; +} + +function lease(id: string, leftMs: number, overrides: Partial = {}): Lease { + return { + deviceId: "dev_leased", + grantedAt: NOW - 60_000, + id, + idChosenByRequester: false, + lastRenewedAt: NOW - 30_000, + ownerId: "tok_1", + requesterId: "tok_1", + ttlDeadline: NOW + leftMs, + ttlMs: 900_000, + ...overrides, + }; +} + +function request(id: string, waitedMs: number): Waiting { + return { + createdAt: NOW - waitedMs, + id, + queuePosition: 1, + requesterId: "agent-b", + spec: { model: "iPhone 16", platform: "ios" }, + stage: "queued", + }; +} + +function ramBudget(overLimit: boolean) { + const entry = { + atRamBudget: false, + limit: 4, + maxRunning: 4, + overLimit: false, + reserved: 0, + running: 1, + used: 1, + warm: 0, + }; + return { + android: entry, + global: { maxRunning: 4, overLimit: false, reserved: 0, running: 1, warm: 0 }, + ios: entry, + ramBudget: { limitBytes: 12 * GIB, overLimit, usedBytes: 13 * GIB }, + }; +} + +const TOKENS: Token[] = [ + { createdAt: NOW - 1_000_000, id: "tok_1", label: "ci-runner-3", role: "agent" }, +]; + +describe("the inbox rules", () => { + it("turn fleet data into items: each worker, device, waiting and ending condition, with its group, order, key and subject", () => { + const failure = { + at: NOW - 120_000, + from: "0.3.0", + message: "EACCES", + stage: "installing", + to: "0.4.0", + } as const; + const workers: WorkerView[] = [ + worker("wrk_ok", { capacity: ramBudget(false) }), + worker("wrk_gone", { connection: "disconnected", drained: true, leases: [lease("l1", 1e6)] }), + worker("wrk_old", { connection: "incompatible" }), + worker("wrk_upd", { + devices: [device("dev_leased", { state: "leased" }), device("dev_idle")], + label: "mac-07", + lastUpdateFailure: failure, + }), + worker("wrk_ram", { capacity: ramBudget(true) }), + worker("wrk_dev", { + devices: [ + device("dev_ready"), + device("dev_q", { state: "quarantined" }), + device("dev_stuck", { state: "reclaiming", stalled: true, transitionAgeMs: 300_000 }), + device("dev_busy", { state: "provisioning", transitionAgeMs: 2_000 }), + device("dev_phone", { + absentReason: "locked", + physical: true, + state: "absent", + }), + ], + label: "mac-08", + }), + ]; + const requests = [ + request("req_old", 60_001), + request("req_edge", 60_000), + request("req_new", 59_000), + request("req_longest", 90_000), + ]; + const leases = [ + lease("lse_far", 360_000), + lease("lse_edge", 300_000), + lease("lse_near", 240_000, { workerId: "wrk_upd" }), + lease("lse_now", 10_000, { deviceId: "dev_gone", requesterId: "gw:g1:agent-9" }), + ]; + + const items = inboxItems(workers, { leases, now: NOW, requests, tokens: TOKENS }); + + expect(items.map((item) => [item.key, item.group, item.condition])).toEqual([ + ["wrk_gone/disconnected", "Broken", "disconnected"], + ["wrk_gone/drained", "Needs a look", "drained"], + ["wrk_old/incompatible", "Broken", "incompatible"], + ["wrk_upd/update-failed", "Needs a look", "update failed"], + ["wrk_ram/ram", "Needs a look", "over RAM budget"], + ["wrk_dev/dev_q/quarantined", "Broken", "quarantined"], + ["wrk_dev/dev_stuck/stalled", "Needs a look", "stalled"], + ["wrk_dev/dev_phone/absent", "Needs a look", "absent"], + ["request/req_longest/waiting", "Needs a look", "waiting"], + ["request/req_old/waiting", "Needs a look", "waiting"], + ["lease/lse_now/ending", "Heads-up", "ending"], + ["lease/lse_near/ending", "Heads-up", "ending"], + ]); + const byKey = (key: string) => items.find((item) => item.key === key); + expect(byKey("wrk_gone/drained")).toMatchObject({ + worker: { heldLeases: 1, id: "wrk_gone", leasedDevices: 0 }, + }); + expect(byKey("wrk_upd/update-failed")).toMatchObject({ + worker: { heldLeases: 0, label: "mac-07", lastUpdateFailure: failure, leasedDevices: 1 }, + }); + expect(byKey("wrk_ram/ram")).toMatchObject({ limitBytes: 12 * GIB, usedBytes: 13 * GIB }); + expect(byKey("wrk_dev/dev_stuck/stalled")).toMatchObject({ + device: { + id: "dev_stuck", + stateEnteredAt: NOW - 300_000, + workerId: "wrk_dev", + workerLabel: "mac-08", + }, + }); + expect(byKey("wrk_dev/dev_q/quarantined")).toMatchObject({ + device: { id: "dev_q", workerId: "wrk_dev" }, + }); + expect(byKey("wrk_dev/dev_phone/absent")).toMatchObject({ + device: { absentReason: "locked", id: "dev_phone" }, + }); + expect(byKey("request/req_old/waiting")).toMatchObject({ + request: { createdAt: NOW - 60_001, id: "req_old", requesterId: "agent-b" }, + }); + expect(byKey("lease/lse_near/ending")).toMatchObject({ + lease: { + holder: "ci-runner-3", + id: "lse_near", + model: "iPhone 16", + ttlDeadline: NOW + 240_000, + }, + }); + expect(byKey("lease/lse_now/ending")).toMatchObject({ + lease: { holder: "agent-9", model: "dev_gone" }, + }); + }); +}); From 7c831bdef522b70400da050b1d7bc09cc04e81d8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:24:12 +0200 Subject: [PATCH 04/18] feat(contract): the inbox rules and their helpers live in one place (#915) --- src/contract/inbox.ts | 209 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 189 insertions(+), 20 deletions(-) diff --git a/src/contract/inbox.ts b/src/contract/inbox.ts index 94ada542a..e2c235ecb 100644 --- a/src/contract/inbox.ts +++ b/src/contract/inbox.ts @@ -1,3 +1,8 @@ +// why: the one set of rules behind `inbox.get` and the helpers they call; plain functions over wire +// types with no runtime import, so the console imports this file by path (ADR 0044 §3) and reads +// what the daemon decided. +import type { z } from "zod"; + import type { inboxItemSchema, statusLeaseSchema, @@ -5,10 +10,12 @@ import type { waitingRequestSchema, workerViewSchema, } from "./schemas.js"; -import type { z } from "zod"; export type InboxItem = z.infer; +type InboxGroup = InboxItem["group"]; +type WorkerItem = Extract; type WorkerView = z.infer; +type WorkerDevice = NonNullable[number]; type LeaseRecord = z.infer; type TokenRecord = z.infer; type WaitingRequest = z.infer; @@ -20,11 +27,95 @@ export interface InboxContext { readonly tokens?: readonly TokenRecord[] | undefined; } -type InboxGroup = InboxItem["group"]; -type WorkerItem = Extract; +const WAITING_TOO_LONG_MS = 60_000; + +// why: a lease with under 5 minutes left is about to end, so the list, the lease drawer, the Overview and the +// Inbox warn by the same rule. +const EXPIRING_UNDER_MS = 5 * 60_000; -// why: a lost or incompatible worker is broken, which is the error tone the console shows; every -// other worker condition needs a look. +export function isExpiring(deadline: number, now: number): boolean { + return deadline - now < EXPIRING_UNDER_MS; +} + +const PLATFORM_NAMES: Readonly>> = { + android: "Android", + ios: "iOS", +}; + +export function platformName(platform: string): string { + return PLATFORM_NAMES[platform] ?? platform; +} + +export function describeRequestedDevice(spec: WaitingRequest["spec"]): string { + return [ + [platformName(spec.platform), spec.model ?? spec.class] + .filter((part) => part !== undefined) + .join(" "), + spec.osVersion === undefined ? undefined : `runtime ${spec.osVersion}`, + spec.mode === undefined ? undefined : `mode ${spec.mode}`, + spec.imageTag === undefined ? undefined : `image tag ${spec.imageTag}`, + ] + .filter((part) => part !== undefined) + .join(", "); +} + +interface Holder { + readonly id: string; + readonly label?: string; +} + +// why: over HTTP a requester's id is its token's id, so a token with that id names it; a requester +// no token matches, such as an agent on the local socket, is its id alone. +export function holderOf(requesterId: string, tokens: readonly TokenRecord[]): Holder { + const id = agentIdOf(requesterId); + const label = tokens.find((token) => token.id === id)?.label; + return label === undefined ? { id } : { id, label }; +} + +const GATEWAY_REQUESTER = /^gw:[^:]+:(.+)$/; + +// why: a gateway hands a worker its agents' requests under `gw::`, so a +// worker's own view names the agent behind that prefix; the console shows the agent, as the +// gateway's lease list does. +export function agentIdOf(requesterId: string): string { + return GATEWAY_REQUESTER.exec(requesterId)?.[1] ?? requesterId; +} + +// why: a holder's title is its token's label, or its id where no label names it. +export function holderName(requesterId: string, tokens: readonly TokenRecord[]): string { + const holder = holderOf(requesterId, tokens); + return holder.label ?? holder.id; +} + +// why: a single host names no worker on its leases, and every lease there is on its one worker, the +// host itself. +export function workerOfLease( + lease: { readonly workerId?: string | undefined }, + workers: readonly WorkerView[], +): WorkerView | undefined { + if (lease.workerId === undefined) return workers.length === 1 ? workers[0] : undefined; + return workers.find((worker) => worker.id === lease.workerId); +} + +export function deviceOfLease( + lease: LeaseRecord, + workers: readonly WorkerView[], +): WorkerDevice | undefined { + return workerOfLease(lease, workers)?.devices?.find((device) => device.id === lease.deviceId); +} + +// why: the age is read only from a connected worker: a gateway moves `lastSeenAt` when a worker +// disconnects, without reading its devices again. +export function stateEnteredAt(device: WorkerDevice, worker: WorkerView): number | undefined { + if (device.state === "leased") { + return worker.leases?.find((lease) => lease.deviceId === device.id)?.grantedAt; + } + if (device.transitionAgeMs === undefined || worker.connection !== "connected") return undefined; + return worker.lastSeenAt - device.transitionAgeMs; +} + +// why: a lost or incompatible worker and a quarantined device are broken, the error tone the console +// shows; a lease about to end is only a heads-up; everything else needs a look. function groupOf(condition: InboxItem["condition"]): InboxGroup { if (condition === "ending") return "Heads-up"; return condition === "disconnected" || condition === "incompatible" || condition === "quarantined" @@ -32,6 +123,55 @@ function groupOf(condition: InboxItem["condition"]): InboxGroup { : "Needs a look"; } +// why: the sidebar and Overview count what is a problem now, so a heads-up is left out. +export function problemCount(items: readonly { readonly group: InboxGroup }[]): number { + return items.filter((item) => item.group !== "Heads-up").length; +} + +// why: a worker that is both disconnected and drained is two items, since each clears on its own. +export function inboxItems( + workers: readonly WorkerView[], + context?: InboxContext, +): readonly InboxItem[] { + return [ + ...workers.flatMap((worker) => [...workerItems(worker), ...deviceItems(worker)]), + ...(context === undefined ? [] : waitingItems(context)), + ...(context === undefined ? [] : endingItems(workers, context)), + ]; +} + +// why: a request waits on the daemon's clock, so the wait is counted from the daemon's now; the +// longest wait comes first. +function waitingItems(context: InboxContext): InboxItem[] { + return [...(context.requests ?? [])] + .sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1)) + .filter((request) => context.now - request.createdAt > WAITING_TOO_LONG_MS) + .map((request) => ({ + condition: "waiting", + group: groupOf("waiting"), + key: `request/${request.id}/waiting`, + request, + })); +} + +// why: a lease's time left counts against the daemon's clock, and the least time left comes first. +function endingItems(workers: readonly WorkerView[], context: InboxContext): InboxItem[] { + const { now, tokens = [] } = context; + return [...(context.leases ?? [])] + .sort((a, b) => a.ttlDeadline - b.ttlDeadline || (a.id < b.id ? -1 : 1)) + .filter((lease) => isExpiring(lease.ttlDeadline, now)) + .map((lease) => ({ + condition: "ending", + group: groupOf("ending"), + key: `lease/${lease.id}/ending`, + lease: { + ...lease, + holder: holderName(lease.requesterId, tokens), + model: deviceOfLease(lease, workers)?.spec.model ?? lease.deviceId, + }, + })); +} + function workerSubject(worker: WorkerView): WorkerItem["worker"] { const { catalog: _catalog, devices, downloads: _downloads, leases, ...rest } = worker; return { @@ -41,29 +181,58 @@ function workerSubject(worker: WorkerView): WorkerItem["worker"] { }; } -// why: a worker that is both disconnected and drained is two items, since each clears on its own. -export function inboxItems( - workers: readonly WorkerView[], - _context?: InboxContext, -): readonly InboxItem[] { - return workers.flatMap(workerItems); -} - function workerItems(worker: WorkerView): InboxItem[] { const items: InboxItem[] = []; const subject = workerSubject(worker); + const key = (condition: string) => `${worker.id}/${condition}`; const add = ( condition: "disconnected" | "incompatible" | "drained" | "update failed", - key: string, - ) => + keyed: string, + ) => items.push({ condition, group: groupOf(condition), key: key(keyed), worker: subject }); + if (worker.connection === "disconnected" || worker.connection === "incompatible") { + add(worker.connection, worker.connection); + } + if (worker.drained) add("drained", "drained"); + if (worker.lastUpdateFailure !== undefined && worker.lastUpdateFailure !== null) { + add("update failed", "update-failed"); + } + const ramBudget = worker.capacity?.ramBudget; + if (ramBudget?.overLimit === true) { items.push({ - condition, - group: groupOf(condition), - key: `${worker.id}/${key}`, + condition: "over RAM budget", + group: groupOf("over RAM budget"), + key: key("ram"), + limitBytes: ramBudget.limitBytes, + usedBytes: ramBudget.usedBytes, worker: subject, }); - if (worker.connection === "disconnected" || worker.connection === "incompatible") { - add(worker.connection, worker.connection); } return items; } + +function deviceItems(worker: WorkerView): InboxItem[] { + return (worker.devices ?? []).flatMap((device): InboxItem[] => { + const condition = deviceCondition(device); + if (condition === undefined) return []; + const since = stateEnteredAt(device, worker); + return [ + { + condition, + device: { + ...device, + workerId: worker.id, + ...(worker.label === undefined ? {} : { workerLabel: worker.label }), + ...(since === undefined ? {} : { stateEnteredAt: since }), + }, + group: groupOf(condition), + key: `${worker.id}/${device.id}/${condition}`, + }, + ]; + }); +} + +function deviceCondition(device: WorkerDevice): "quarantined" | "stalled" | "absent" | undefined { + if (device.state === "quarantined") return "quarantined"; + if (device.stalled === true) return "stalled"; + return device.state === "absent" ? "absent" : undefined; +} From d49ff701aa9b5cb998b0b7e5c6b6250ac52f2e60 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:25:07 +0200 Subject: [PATCH 05/18] test: inbox.get reads the fleet's leases, waiting requests and tokens (#915) --- src/gateway/dispatcher.test.ts | 57 ++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index 8b3e20169..6ccb9b0d5 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -961,28 +961,45 @@ describe("GatewayDispatcher", () => { }); it("inbox.get answers the items the fleet's views, leases, waiting requests and tokens make", async () => { - const { dispatcher, workers } = harness(); - workers.connected("wrk_1", "mac-mini-1", "0.3.0"); - workers.connected("wrk_2", "mac-mini-2", "0.3.0"); - workers.disconnected("wrk_2"); + const holders = async (options: Parameters[0]) => { + const { clock, dispatcher, workers } = harness(options); + workers.connected("wrk_1", "mac-mini-1", "0.3.0"); + workers.refresh("wrk_1", { + devices: [deviceFixture("dev_1", "leased")], + health: "running", + leases: [{ ...leaseFixture("lease_1", "dev_1"), ttlDeadline: 121_000 }], + waiting: [ + { + createdAt: 500, + id: "req_1", + requesterId: "agent-2", + spec: { model: "iPhone 17", platform: "ios" }, + stage: "starting", + }, + ], + }); + workers.connected("wrk_2", "mac-mini-2", "0.3.0"); + workers.disconnected("wrk_2"); + clock.advance(60_000); + const { items } = await dispatcher.dispatch("inbox.get", {}, session()); + return items; + }; - const answer = await dispatcher.dispatch("inbox.get", {}, session()); + const named = await holders({ tokenRecords: [{ id: "agent-1", label: "ci-runner" }] }); - expect(answer).toEqual({ - items: [ - { - condition: "disconnected", - group: "Broken", - key: "wrk_2/disconnected", - worker: expect.objectContaining({ - connection: "disconnected", - heldLeases: 0, - id: "wrk_2", - leasedDevices: 0, - }), - }, - ], - }); + expect(named).toEqual([ + expect.objectContaining({ condition: "disconnected", key: "wrk_2/disconnected" }), + expect.objectContaining({ condition: "waiting", key: "request/req_1/waiting" }), + expect.objectContaining({ + condition: "ending", + key: "lease/wrk_1.lease_1/ending", + lease: expect.objectContaining({ holder: "ci-runner", model: "iPhone 17" }), + }), + ]); + const unnamed = await holders({ withoutTokens: true }); + expect(unnamed).toContainEqual( + expect.objectContaining({ lease: expect.objectContaining({ holder: "agent-1" }) }), + ); }); it("lists each of an owner's gateway-issued leases under its own record, and none whose worker has no view", async () => { From 0934b1bae9f5692d352c5bb511556134c88f3787 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:25:43 +0200 Subject: [PATCH 06/18] feat(gateway): inbox.get reads the fleet's leases, waiting requests and tokens (#915) --- src/gateway/dispatcher.test.ts | 2 +- src/gateway/dispatcher.ts | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index 6ccb9b0d5..1f7189ccf 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -992,7 +992,7 @@ describe("GatewayDispatcher", () => { expect.objectContaining({ condition: "waiting", key: "request/req_1/waiting" }), expect.objectContaining({ condition: "ending", - key: "lease/wrk_1.lease_1/ending", + key: "lease/lease_1/ending", lease: expect.objectContaining({ holder: "ci-runner", model: "iPhone 17" }), }), ]); diff --git a/src/gateway/dispatcher.ts b/src/gateway/dispatcher.ts index 39448ce01..1d237edb0 100644 --- a/src/gateway/dispatcher.ts +++ b/src/gateway/dispatcher.ts @@ -413,10 +413,24 @@ export class GatewayDispatcher { return { revoked }; }; - // why: the rules read the same views, as `worker.list` serves them. - #inboxGet: Handler<"inbox.get"> = () => ({ - items: [...inboxItems(this.options.workers.views())], - }); + // why: the rules read what `worker.list`, `lease.list`, `list.get` and `token.list` serve an admin, so + // the Inbox never disagrees with the pages that list the same things; a token store that is + // unavailable leaves holders named by their ids, as the console did with no tokens. + #inboxGet: Handler<"inbox.get"> = async () => { + const tokens = await Promise.resolve() + .then(() => this.#requireTokens().list()) + .catch(() => []); + return { + items: [ + ...inboxItems(this.options.workers.views(), { + leases: this.#fleetLeases(), + now: this.options.clock.now(), + requests: this.#fleetWaiting(), + tokens, + }), + ], + }; + }; #workerList: Handler<"worker.list"> = () => ({ workers: this.options.workers.views() }); From e17efd7d47b8f9c18504b007d862866253000d49 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:26:37 +0200 Subject: [PATCH 07/18] test: inbox.get on a worker answers a lease about to end and a request that has waited over 60 s (#915) --- src/daemon/dispatcher.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 6008dc39f..fc78d1ca4 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -2320,6 +2320,27 @@ describe("Dispatcher: list.get requests and status.get waiting", () => { const requests = (dispatcher: Dispatcher) => dispatcher.dispatch("list.get", { kind: "requests" }, session({ role: "admin" })); + it("inbox.get on a worker answers a lease about to end and a request that has waited over 60 s", async () => { + const { clock, dispatcher, engine } = await buildDispatcher(); + await request(dispatcher, "holder-1", { ttlMs: 1_000 }); + await request(dispatcher, "holder-2"); + void request(dispatcher, "agent-a"); + await expect.poll(() => engine.queueDepth).toBe(1); + clock.advance(60_001); + + const { items } = await dispatcher.dispatch("inbox.get", {}, session({ role: "admin" })); + + expect(items.map((item) => [item.key.replace(/\/[^/]+\//, "/id/"), item.condition])).toEqual([ + ["request/id/waiting", "waiting"], + ["lease/id/ending", "ending"], + ]); + expect(items).toContainEqual( + expect.objectContaining({ + lease: expect.objectContaining({ holder: "holder-1", model: "iPhone 17 Pro" }), + }), + ); + }); + it("list.get requests on a worker returns every open request with its queue position", async () => { const { clock, dispatcher, engine } = await buildDispatcher(); await fillIos(dispatcher); From 32814fe40d761eb05db6ce327ea95bc79bfe40c2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:27:50 +0200 Subject: [PATCH 08/18] feat(daemon): inbox.get on a worker reads its own view, leases and queue (#915) --- src/daemon/dispatcher.test.ts | 2 +- src/daemon/dispatcher.ts | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index fc78d1ca4..74f57304d 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -2322,7 +2322,7 @@ describe("Dispatcher: list.get requests and status.get waiting", () => { it("inbox.get on a worker answers a lease about to end and a request that has waited over 60 s", async () => { const { clock, dispatcher, engine } = await buildDispatcher(); - await request(dispatcher, "holder-1", { ttlMs: 1_000 }); + await request(dispatcher, "holder-1", { ttlMs: 250_000 }); await request(dispatcher, "holder-2"); void request(dispatcher, "agent-a"); await expect.poll(() => engine.queueDepth).toBe(1); diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index c6bb57e21..5b8b429ed 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -58,11 +58,13 @@ import { consoleUrlField, EVENTS_PAGE_DEFAULT, fitPlatformCatalog, + inboxItems, OPERATIONS, requestedDevice, type ComponentProgress, type OperationName, type StatusLoad, + waitingRequestSchema, WORKER_VIEW_CATALOG_EVENTS, WORKER_VIEW_REFRESH_INTERVAL_MS, workerViewFields, @@ -1053,7 +1055,25 @@ export class Dispatcher { return { secret, token: record }; }; - #inboxGet: Handler<"inbox.get"> = () => ({ items: [] }); + // why: a worker answers as a fleet of one (ADR 0012): the rules read its own view, leases and queue. + #inboxGet: Handler<"inbox.get"> = async (_input, session) => { + const [listed, tokens] = await Promise.all([ + this.#workerList({}, session), + Promise.resolve() + .then(() => this.#requireTokens().list()) + .catch(() => []), + ]); + return { + items: [ + ...inboxItems(OPERATIONS["worker.list"].output.parse(listed).workers, { + leases: OPERATIONS["lease.list"].output.parse(this.#leaseList({}, session)).leases, + now: this.options.clock.now(), + requests: z.array(waitingRequestSchema).parse(this.options.queue.waitingRequests()), + tokens: OPERATIONS["token.list"].output.parse({ tokens }).tokens, + }), + ], + }; + }; #tokenList: Handler<"token.list"> = async () => ({ tokens: await this.#requireTokens().list() }); From e033f27735a4b94e994ff52f3c1a931c3fe385bd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:28:28 +0200 Subject: [PATCH 09/18] test: GET /v1/inbox dispatches inbox.get for the caller and answers its items (#915) --- src/http/app.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 0901ea646..c0fe5e917 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -2788,6 +2788,32 @@ describe("worker routes", () => { expect(await response.json()).toMatchObject({ workers: [{ id: "wrk_1" }] }); }); + it("GET /v1/inbox dispatches inbox.get for the caller and answers its items", async () => { + const { app, dispatcher } = gatewayHarness(); + const item = { + condition: "disconnected", + group: "Broken", + key: "wrk_1/disconnected", + worker: { + connection: "disconnected", + drained: false, + heldLeases: 0, + id: "wrk_1", + lastSeenAt: 1, + leasedDevices: 0, + }, + }; + dispatcher.handlers["inbox.get"] = () => ({ items: [item] }); + + const response = await app.request("/v1/inbox", { headers: operatorAuth }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ items: [item] }); + expect(dispatcher.calls.map((call) => [call.operation, call.session.role])).toEqual([ + ["inbox.get", "admin"], + ]); + }); + it("maps drain, undrain and remove onto their operations, with the id from the path", async () => { const { app, dispatcher } = gatewayHarness(); dispatcher.handlers["worker.drain"] = (input: never) => ({ From abf7ac3098b6aaf3f90134475de17f79ba00e47e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:29:06 +0200 Subject: [PATCH 10/18] feat(http): GET /v1/inbox (#915) --- src/contract/protocol.ts | 1 + src/http/AGENTS.md | 1 + src/http/admin-declarations.ts | 12 ++++++++++++ src/http/app.ts | 5 +++++ src/http/core-routes.test.ts | 3 ++- 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index 45f8bc6ca..4d3b6f979 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -86,6 +86,7 @@ export const CORE_ROUTE_SEGMENTS: readonly string[] = [ "events", "healthz", "identity", + "inbox", "lease-requests", "leases", "stats", diff --git a/src/http/AGENTS.md b/src/http/AGENTS.md index 9e18173d2..f9d64d1f3 100644 --- a/src/http/AGENTS.md +++ b/src/http/AGENTS.md @@ -90,6 +90,7 @@ agent-device front and the stream (`/v1/stream`). subscribes to the `EventBus` itself. - `GET /v1/events` — reads `since`, `before` and `limit` through `eventsPageQuery` (contract), refused as 400 before dispatching `events.replay`; `HISTORY_NOT_KEPT` maps to 422 with `oldestTs`, `HISTORY_UNAVAILABLE` to 503 here too. - `GET /v1/stats` — parses `since`/`from`/`to` with `usageWindow` (contract) and `ledger=1`, all refused as 400 before dispatching `usage.get` (the dispatcher answers an agent token 403, `HISTORY_NOT_KEPT` maps to 422 with `oldestTs`, `HISTORY_UNAVAILABLE` to 503). +- `GET /v1/inbox` → `inbox.get` (admin): the items that need the operator, decided by the daemon (`src/contract/inbox.ts`); an agent token gets 403 from the dispatcher. - `POST /v1/update` (optional body `{version, force}`, strict) → `daemon.update`: 202 when the call began the update (`started`), 200 when it followed one or found nothing to do (`upToDate`). The route declares both (`alsoStatus`). The role check is the dispatcher's; on a gateway it updates the gateway itself, with no lease wait. - `POST /v1/workers/{id}/update` (optional body `{version, force}`, strict) → `worker.update`: the same 202 and 200; `UNKNOWN_WORKER` 404, `WORKER_NOT_CONNECTED` 409, and a worker daemon answers 501. - `GET /v1/leases/{id}/events` — the lease's notices; ends on `lease_lost`. diff --git a/src/http/admin-declarations.ts b/src/http/admin-declarations.ts index d020161dd..823aac969 100644 --- a/src/http/admin-declarations.ts +++ b/src/http/admin-declarations.ts @@ -18,6 +18,17 @@ export const workerListRoute = declareRoute({ reply: OPERATIONS["worker.list"].output, }); +export const inboxRoute = declareRoute({ + method: "get", + path: "/v1/inbox", + tag: "Workers", + summary: "What needs the operator: the Inbox items", + role: "admin", + notes: + "The daemon decides: workers that are lost, incompatible, drained, failed to update or over their RAM budget; devices that are quarantined, stalled or absent; requests that have waited over 60 s; leases with under 5 minutes left. A gateway answers for its fleet, a worker for itself.", + reply: OPERATIONS["inbox.get"].output, +}); + export const workerDrainRoute = declareRoute({ method: "post", path: "/v1/workers/{id}/drain", @@ -222,6 +233,7 @@ export const componentInstallRoute = declareRoute({ export const ADMIN_ROUTES = [ updateRoute, workerListRoute, + inboxRoute, workerDrainRoute, workerUndrainRoute, workerUpdateRoute, diff --git a/src/http/app.ts b/src/http/app.ts index 915704bb4..1a0c528d4 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -26,6 +26,7 @@ import { tokenListRoute, updateRoute, workerDrainRoute, + inboxRoute, workerListRoute, workerRemoveRoute, workerUndrainRoute, @@ -651,6 +652,10 @@ export function createHttpApp(deps: HttpGatewayDeps): OpenAPIHono & HttpApp c.json(await deps.dispatch("worker.list", {}, buildHttpSession(c.get("identity"))), 200), ); + app.openapi({ ...inboxRoute, middleware: [agentAuth] }, async (c) => + c.json(await deps.dispatch("inbox.get", {}, buildHttpSession(c.get("identity"))), 200), + ); + app.openapi({ ...updateRoute, middleware: [agentAuth, emptyBodyAsNone] }, async (c) => { const update = await deps.dispatch( "daemon.update", diff --git a/src/http/core-routes.test.ts b/src/http/core-routes.test.ts index fe7619101..adad31f54 100644 --- a/src/http/core-routes.test.ts +++ b/src/http/core-routes.test.ts @@ -98,7 +98,7 @@ describe("the routes createHttpApp registers", () => { ).toEqual(["declared but not registered: GET /v1/status"]); }); - it("CORE_ROUTE_SEGMENTS names the twelve core first segments and uplink", () => { + it("CORE_ROUTE_SEGMENTS names the core first segments and uplink", () => { expect([...CORE_ROUTE_SEGMENTS].sort()).toEqual( [ "catalog", @@ -107,6 +107,7 @@ describe("the routes createHttpApp registers", () => { "events", "healthz", "identity", + "inbox", "lease-requests", "leases", "stats", From 63657de2a0b7d0a15bed597fb78422745b89eda8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:30:34 +0200 Subject: [PATCH 11/18] test: GET /v1/inbox on a worker answers an operator and refuses an agent (#915) --- e2e/single-host-fleet.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/e2e/single-host-fleet.test.ts b/e2e/single-host-fleet.test.ts index 4ac8c5272..5d8fa6f7c 100644 --- a/e2e/single-host-fleet.test.ts +++ b/e2e/single-host-fleet.test.ts @@ -124,6 +124,18 @@ describe("a single host as a fleet of one", () => { expect(((await response.json()) as { error: { code: string } }).error.code).toBe("FORBIDDEN"); }); + it("GET /v1/inbox on a worker answers 200 with its items for an operator token and 403 for an agent token", async () => { + const host = await httpHost(); + + const operator = await fetch(`${host.baseUrl}/v1/inbox`, { headers: host.operatorAuth }); + const agent = await fetch(`${host.baseUrl}/v1/inbox`, { headers: host.agentAuth }); + + expect(operator.status).toBe(200); + expect(await operator.json()).toEqual({ items: [] }); + expect(agent.status).toBe(403); + expect(((await agent.json()) as { error: { code: string } }).error.code).toBe("FORBIDDEN"); + }); + it("POST /v1/workers/{id}/drain on a worker answers 501 UNSUPPORTED_IN_WORKER_MODE", async () => { const host = await httpHost(); const id = await instanceId(host.env); From 3b84e3474b4f4c4eba5f59e912864161e110a202 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:37:16 +0200 Subject: [PATCH 12/18] test: the sidebar count, Inbox and Needs you show the server's items (#915) --- .dependency-cruiser.mjs | 3 +- ui/src/model/actions.test.ts | 4 +- ui/src/model/actions.ts | 34 +-- ui/src/model/activity.ts | 2 +- ui/src/model/event-list.ts | 5 +- ui/src/model/inbox.test.ts | 187 +++------------ ui/src/model/inbox.ts | 266 ++++++---------------- ui/src/model/leases.test.ts | 2 +- ui/src/model/leases.ts | 62 +---- ui/src/model/overview.test.ts | 13 +- ui/src/model/overview.ts | 51 +---- ui/src/model/stale-keys.ts | 10 +- ui/src/model/usage.ts | 2 +- ui/src/model/versions.ts | 14 +- ui/src/model/waiting.test.ts | 9 +- ui/src/model/waiting.ts | 18 +- ui/src/model/worker-page.ts | 5 +- ui/src/model/workers.ts | 24 +- ui/src/screens/inbox/index.test.tsx | 18 +- ui/src/screens/inbox/index.tsx | 19 +- ui/src/screens/overview/index.tsx | 7 +- ui/src/screens/overview/overview.test.tsx | 49 +--- ui/src/state/inbox.test.ts | 95 -------- ui/src/state/inbox.ts | 81 +------ ui/src/state/overview.ts | 42 ++-- ui/src/state/queries.test.ts | 36 +-- ui/src/state/queries.ts | 1 + ui/src/state/shell.ts | 15 +- 28 files changed, 264 insertions(+), 810 deletions(-) delete mode 100644 ui/src/state/inbox.test.ts diff --git a/.dependency-cruiser.mjs b/.dependency-cruiser.mjs index 77dc767ff..8724a5368 100644 --- a/.dependency-cruiser.mjs +++ b/.dependency-cruiser.mjs @@ -26,9 +26,10 @@ const components = findComponents("src") // why: the console imports the contract's `isNewerVersion` as a value; the index also exports the // zod schemas, which would put zod's `eval` in the bundle and break its Content-Security-Policy. // Maintainer-authorized 2026-10-22 (#825 Decisions, "Console import of the newer rule"): this one file only. +// `inbox.ts` is the same case (ADR 0044 §3, #915): the rules the daemon and the console share, with no runtime import. const entryFiles = (component) => component === "src/contract" - ? "index|testing|definition|version-order" + ? "index|testing|definition|version-order|inbox" : "index|testing|definition"; const pastIndex = components.map((component) => ({ diff --git a/ui/src/model/actions.test.ts b/ui/src/model/actions.test.ts index 85f79d0dd..adf791c11 100644 --- a/ui/src/model/actions.test.ts +++ b/ui/src/model/actions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { inboxActions, leaseActions, requestActions, workerActions } from "./actions"; -import { inboxItems } from "./inbox"; +import { inboxItems } from "../../../src/contract/inbox"; import type { LeaseRecord } from "./leases"; import type { UpdateFacts } from "./versions"; import type { WaitingRequest } from "./waiting"; @@ -163,7 +163,7 @@ function checksInbox(): void { ], }), ]; - const context = { mode: "gateway", now: NOW, tokens: [], workers } as const; + const context = { mode: "gateway", workers } as const; const items = inboxItems(workers, { leases: [ending], now: NOW, requests: [waiting] }); const of = (key: string) => { const item = items.find((candidate) => candidate.key === key); diff --git a/ui/src/model/actions.ts b/ui/src/model/actions.ts index 74f73c801..55f01bd47 100644 --- a/ui/src/model/actions.ts +++ b/ui/src/model/actions.ts @@ -1,9 +1,10 @@ // why: ADR 0037 §3. One action list per kind of item, read by every surface that offers it; the // wording of each dialog and toast is fixed here, so no surface words it differently. +import { describeRequestedDevice, workerOfLease } from "../../../src/contract/inbox"; import type { InboxItem } from "./inbox"; -import { type TokenRecord, leaseRows } from "./leases"; -import { isBehind, type UpdateFacts, updateFactsOf } from "./versions"; -import { requestedDevice } from "./waiting"; +import { workerNameOfLease } from "./leases"; +import { devicePath } from "./paths"; +import { isBehind, type UpdateFacts, updateFactsFrom } from "./versions"; import { type WorkerView, workerName } from "./workers"; export type DaemonModeName = "gateway" | "worker"; @@ -295,8 +296,7 @@ const FIX_OF: Readonly> export interface InboxActionContext { readonly mode: DaemonModeName | undefined; - readonly now: number; - readonly tokens: readonly TokenRecord[]; + // why: an ending lease's Release names its worker and links its device, which the worker list knows. readonly workers: readonly WorkerView[]; } @@ -311,20 +311,26 @@ export function inboxActions( case "waiting": return { menu: requestActions({ - device: requestedDevice(item.request.spec), + device: describeRequestedDevice(item.request.spec), id: item.request.id, key: `${item.request.workerId ?? ""}/${item.request.id}`, requester: item.request.requesterId, }), }; case "ending": { - const [row] = leaseRows({ - leases: [item.lease], - now: context.now, - tokens: context.tokens, - workers: context.workers, - }); - return { menu: row === undefined ? [] : leaseActions(row) }; + const { lease } = item; + const owner = workerOfLease(lease, context.workers); + const worker = workerNameOfLease(lease, context.workers); + return { + menu: leaseActions({ + holder: lease.holder, + id: lease.id, + key: `${lease.workerId ?? ""} ${lease.id}`, + model: lease.model, + ...(worker === undefined ? {} : { worker }), + ...(owner === undefined ? {} : { deviceTo: devicePath(owner.id, lease.deviceId) }), + }), + }; } case "quarantined": case "stalled": @@ -341,7 +347,7 @@ export function inboxActions( drained: item.worker.drained, id: item.worker.id, name: workerName(item.worker), - updateFacts: updateFactsOf(item.worker), + updateFacts: updateFactsFrom(item.worker, item.worker.leasedDevices), }, context.mode, ); diff --git a/ui/src/model/activity.ts b/ui/src/model/activity.ts index 15cffda91..e0f0ebc72 100644 --- a/ui/src/model/activity.ts +++ b/ui/src/model/activity.ts @@ -1,5 +1,5 @@ +import { agentIdOf } from "../../../src/contract/inbox"; import { type ConsoleEvent, workerIdOf } from "./events"; -import { agentIdOf } from "./leases"; import type { Tone } from "./tones"; import { type WorkerView, workerName } from "./workers"; diff --git a/ui/src/model/event-list.ts b/ui/src/model/event-list.ts index 69ad33b8c..810028520 100644 --- a/ui/src/model/event-list.ts +++ b/ui/src/model/event-list.ts @@ -1,5 +1,5 @@ import { eventSummary, eventTone } from "./activity"; -import { inboxItems } from "./inbox"; +import { inboxItems } from "../../../src/contract/inbox"; import { type ConsoleEvent, payloadPairs, @@ -154,7 +154,8 @@ export function eventAction(event: ConsoleEvent, places: EventPlaces): EventActi } const deviceId = textField(payload, "deviceId"); const item = inboxItems(places.workers).find( - (entry) => "device" in entry && entry.worker.id === workerId && entry.device.id === deviceId, + (entry) => + "device" in entry && entry.device.workerId === workerId && entry.device.id === deviceId, ); if (item !== undefined) { return { label: "Open Inbox item", to: inboxPath(item.key) }; diff --git a/ui/src/model/inbox.test.ts b/ui/src/model/inbox.test.ts index 58dd17225..09e277fcf 100644 --- a/ui/src/model/inbox.test.ts +++ b/ui/src/model/inbox.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { filterInboxRows, inboxItems, inboxPageOf } from "./inbox"; +import { inboxItems } from "../../../src/contract/inbox"; +import { filterInboxRows, inboxPageOf } from "./inbox"; import type { ConsoleEvent } from "./events"; import { filterLeaseRows, type LeaseRecord, leaseRows, type TokenRecord } from "./leases"; import type { WaitingRequest } from "./waiting"; @@ -56,14 +57,29 @@ function event(seq: number, name: string, at: number, payload: unknown = {}): Co return { event: name, id: `e${seq}`, payload, seq, timestamp: at }; } -function summary(workers: readonly WorkerView[]): string[] { - return inboxItems(workers).map((item) => - [ - "worker" in item ? item.worker.id : "-", - "device" in item ? item.device.id : "-", - item.condition, - ].join(" "), - ); +// why: the items are the daemon's; the contract's own rules build them here, so a page is checked +// against what `inbox.get` would send for the same fleet. +function pageWith(input: { + readonly workers: readonly WorkerView[]; + readonly events?: readonly ConsoleEvent[]; + readonly key?: string | undefined; + readonly requests?: readonly WaitingRequest[]; + readonly leases?: readonly LeaseRecord[]; + readonly tokens?: readonly TokenRecord[]; +}) { + const { workers } = input; + return inboxPageOf({ + events: input.events ?? [], + items: inboxItems(workers, { + leases: input.leases, + now: NOW, + requests: input.requests, + tokens: input.tokens, + }), + key: input.key, + now: NOW, + workers, + }); } function pageOf( @@ -71,7 +87,7 @@ function pageOf( key?: string, events: readonly ConsoleEvent[] = [], ) { - return inboxPageOf({ events, key, now: NOW, workers }); + return pageWith({ events, key, workers }); } function shownOf(page: ReturnType) { @@ -83,100 +99,6 @@ function factsOf(w: WorkerView, key: string) { return shownOf(pageOf([w], key)).facts; } -describe("the Inbox items", () => { - it("hold a disconnected, an incompatible and a drained worker, one item per condition", () => { - const workers = [ - worker({ id: "wrk_ok" }), - worker({ connection: "disconnected", id: "wrk_gone" }), - worker({ connection: "incompatible", id: "wrk_old" }), - worker({ drained: true, id: "wrk_drained" }), - worker({ connection: "disconnected", drained: true, id: "wrk_both" }), - ]; - - expect(summary(workers)).toEqual([ - "wrk_gone - disconnected", - "wrk_old - incompatible", - "wrk_drained - drained", - "wrk_both - disconnected", - "wrk_both - drained", - ]); - }); - - it("hold a quarantined device and a stalled device, not one mid-transition", () => { - const workers = [ - worker({ - devices: [ - device("dev_ready"), - device("dev_quarantined", { state: "quarantined" }), - device("dev_stuck", { stalled: true, state: "reclaiming", transitionAgeMs: 300_000 }), - device("dev_busy", { state: "provisioning", transitionAgeMs: 2_000 }), - ], - }), - ]; - - expect(summary(workers)).toEqual([ - "wrk_1 dev_quarantined quarantined", - "wrk_1 dev_stuck stalled", - ]); - }); - - it("hold a quarantined physical device like any quarantined device", () => { - const physical: WorkerDevice = { - class: "phone", - id: "dev_phone", - physical: true, - servesDefaultMode: false, - spec: { model: "iPhone 15 Pro", osVersion: "18.4", physical: true, platform: "ios" }, - state: "quarantined", - }; - - expect(summary([worker({ devices: [physical] })])).toEqual(["wrk_1 dev_phone quarantined"]); - }); - - it("hold a worker over its RAM budget, not one under it or with no budget", () => { - const workers = [ - worker({ - capacity: capacity({ limitBytes: 12 * GIB, overLimit: true, usedBytes: 13 * GIB }), - id: "wrk_over", - }), - worker({ - capacity: capacity({ limitBytes: 12 * GIB, overLimit: false, usedBytes: 4 * GIB }), - id: "wrk_under", - }), - worker({ capacity: capacity(), id: "wrk_none" }), - worker({ id: "wrk_unread" }), - ]; - - expect(summary(workers)).toEqual(["wrk_over - over RAM budget"]); - }); - - it("lose an item when its condition clears", () => { - const quarantined = device("dev_1", { state: "quarantined" }); - const stalled = device("dev_2", { stalled: true, state: "provisioning" }); - const ramBudget = { limitBytes: 12 * GIB, usedBytes: 13 * GIB }; - const before = worker({ - capacity: capacity({ ...ramBudget, overLimit: true }), - connection: "disconnected", - devices: [quarantined, stalled], - drained: true, - }); - expect(inboxItems([before])).toHaveLength(5); - - const cleared: [string, WorkerView][] = [ - ["disconnected", { ...before, connection: "connected" }], - ["drained", { ...before, drained: false }], - ["over RAM budget", { ...before, capacity: capacity({ ...ramBudget, overLimit: false }) }], - ["quarantined", { ...before, devices: [{ ...quarantined, state: "ready" }, stalled] }], - ["stalled", { ...before, devices: [quarantined, device("dev_2", { state: "ready" })] }], - ]; - for (const [condition, after] of cleared) { - const conditions = inboxItems([after]).map((item) => item.condition); - expect(conditions, condition).toHaveLength(4); - expect(conditions, condition).not.toContain(condition); - } - }); -}); - describe("the Inbox items of an absent phone", () => { it("hold an absent phone titled with its model and why, and not a phone in another state", () => { const phone = ( @@ -227,10 +149,9 @@ describe("the Inbox items of a long wait", () => { stage: "queued", }); const rowsAt = (waitedMs: number) => - inboxPageOf({ + pageWith({ events: [], key: undefined, - now: NOW, requests: [waiting(waitedMs)], workers: [worker()], }).rows.map((row) => [row.key, row.word, row.tone, row.subject]); @@ -240,9 +161,6 @@ describe("the Inbox items of a long wait", () => { expect(rowsAt(60_001)).toEqual([ ["request/req_1/waiting", "waiting", "warn", "agent-b has waited 1 min"], ]); - expect( - inboxItems([worker()], { now: NOW, requests: [waiting(61_000)] }).map((item) => item.key), - ).toEqual(["request/req_1/waiting"]); }); }); @@ -273,7 +191,7 @@ describe("the Inbox items of a lease about to end", () => { { ...lease("lse_lost", 10_000), deviceId: "dev_gone" }, ]; - const page = inboxPageOf({ events: [], key: undefined, leases, now: NOW, tokens, workers }); + const page = pageWith({ events: [], key: undefined, leases, tokens, workers }); expect(page.rows.map((row) => [row.key, row.word, row.subject])).toEqual([ ["lease/lse_past/ending", "ending", "ci-runner-3 · iPhone 16 ends now"], @@ -317,7 +235,7 @@ describe("the drawer of a waiting request and of a lease about to end", () => { ]; const workers = [worker({ devices: [device("dev_1")] })]; const shown = (key: string) => - shownOf(inboxPageOf({ events: [], key, leases, now: NOW, requests, workers })); + shownOf(pageWith({ events: [], key, leases, requests, workers })); const waiting = shown("request/req_1/waiting"); const ending = shown("lease/wrk_1.lse_1/ending"); @@ -348,50 +266,6 @@ describe("the drawer of a waiting request and of a lease about to end", () => { }); }); -describe("the order of the Inbox items", () => { - it("keeps the order given for problems, puts the longest wait first and the least time left first", () => { - const request = (id: string, waitedMs: number): WaitingRequest => ({ - createdAt: NOW - waitedMs, - id, - queuePosition: 1, - requesterId: "agent-b", - spec: { model: "iPhone 16", platform: "ios" }, - stage: "queued", - }); - const lease = (id: string, leftMs: number): LeaseRecord => ({ - deviceId: "dev_1", - grantedAt: NOW - 60_000, - id, - idChosenByRequester: false, - lastRenewedAt: NOW - 30_000, - ownerId: "tok_1", - requesterId: "tok_1", - ttlDeadline: NOW + leftMs, - ttlMs: 900_000, - }); - const workers = [ - worker({ drained: true, id: "wrk_z" }), - worker({ drained: true, id: "wrk_a", devices: [device("dev_2", { state: "quarantined" })] }), - ]; - - const keys = inboxItems(workers, { - leases: [lease("lse_b", 200_000), lease("lse_a", 100_000)], - now: NOW, - requests: [request("req_new", 70_000), request("req_old", 90_000)], - }).map((item) => item.key); - - expect(keys).toEqual([ - "wrk_z/drained", - "wrk_a/drained", - "wrk_a/dev_2/quarantined", - "request/req_old/waiting", - "request/req_new/waiting", - "lease/lse_a/ending", - "lease/lse_b/ending", - ]); - }); -}); - describe("the Inbox groups", () => { it("group the rows as Broken, Needs a look, Heads-up, and count problems without the heads-ups", () => { const workers = [ @@ -418,11 +292,10 @@ describe("the Inbox groups", () => { ttlMs: 900_000, }; - const page = inboxPageOf({ + const page = pageWith({ events: [], key: undefined, leases: [lease, { ...lease, id: "lse_2", ttlDeadline: NOW + 200_000 }], - now: NOW, requests: [request], workers, }); diff --git a/ui/src/model/inbox.ts b/ui/src/model/inbox.ts index 0a20d936f..019c1407b 100644 --- a/ui/src/model/inbox.ts +++ b/ui/src/model/inbox.ts @@ -1,181 +1,38 @@ +import type { z } from "zod"; + +import { describeRequestedDevice, problemCount } from "../../../src/contract/inbox"; +import type { inboxItemSchema } from "../../../src/contract/index"; import { eventSummary, eventTone } from "./activity"; import { type ConsoleEvent, timeOfDay, workerIdOf } from "./events"; +import { eventsOfLease, leasePath } from "./leases"; import { inboxPath, itemPath } from "./paths"; -import { - deviceOfLease, - eventsOfLease, - holderName, - isExpiring, - type LeaseRecord, - leasePath, - type TokenRecord, -} from "./leases"; import { formatDuration } from "./time"; -import { requestedDevice, type WaitingRequest } from "./waiting"; import type { Tone } from "./tones"; -import { - stateEnteredAt, - type WorkerDevice, - type WorkerView, - workerName, - workerPath, -} from "./workers"; - -export type InboxItem = - | { - readonly key: string; - readonly condition: "disconnected" | "incompatible" | "drained" | "update failed"; - readonly worker: WorkerView; - } - | { - readonly key: string; - readonly condition: "over RAM budget"; - readonly worker: WorkerView; - readonly usedBytes: number; - readonly limitBytes: number; - } - | { - readonly key: string; - readonly condition: "quarantined" | "stalled" | "absent"; - readonly worker: WorkerView; - readonly device: WorkerDevice; - } - | { - readonly key: string; - readonly condition: "waiting"; - readonly request: WaitingRequest; - readonly waitedMs: number; - } - | { - readonly key: string; - readonly condition: "ending"; - readonly lease: LeaseRecord; - readonly holder: string; - readonly model: string; - readonly leftMs: number; - }; +import { type WorkerView, workerName, workerPath } from "./workers"; -type WorkerItem = Extract; +// why: the item is the daemon's (`inbox.get`): the console decides nothing about what needs the operator. +export type InboxItem = z.infer; -const WAITING_TOO_LONG_MS = 60_000; - -export interface InboxContext { - readonly now: number; - readonly requests?: readonly WaitingRequest[] | undefined; - readonly leases?: readonly LeaseRecord[] | undefined; - readonly tokens?: readonly TokenRecord[] | undefined; -} - -// why: a worker that is both disconnected and drained is two items, since each clears on its own. -export function inboxItems( - workers: readonly WorkerView[], - context?: InboxContext, -): readonly InboxItem[] { - return [ - ...workers.flatMap((worker) => [...workerItems(worker), ...deviceItems(worker)]), - ...(context === undefined ? [] : waitingItems(context)), - ...(context === undefined ? [] : endingItems(workers, context)), - ]; -} - -// why: a request waits on the daemon's clock, so the wait is counted from the daemon's now; the -// longest wait comes first. -function waitingItems(context: InboxContext): InboxItem[] { - return [...(context.requests ?? [])] - .sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1)) - .flatMap((request): InboxItem[] => { - const waitedMs = context.now - request.createdAt; - return waitedMs > WAITING_TOO_LONG_MS - ? [{ condition: "waiting", key: `request/${request.id}/waiting`, request, waitedMs }] - : []; - }); -} - -// why: a lease's time left counts against the daemon's clock, and the least time left comes first. -function endingItems(workers: readonly WorkerView[], context: InboxContext): InboxItem[] { - const { now, tokens = [] } = context; - return [...(context.leases ?? [])] - .sort((a, b) => a.ttlDeadline - b.ttlDeadline || (a.id < b.id ? -1 : 1)) - .flatMap((lease): InboxItem[] => { - if (!isExpiring(lease.ttlDeadline, now)) return []; - return [ - { - condition: "ending", - holder: holderName(lease.requesterId, tokens), - key: `lease/${lease.id}/ending`, - lease, - leftMs: lease.ttlDeadline - now, - model: deviceOfLease(lease, workers)?.spec.model ?? lease.deviceId, - }, - ]; - }); -} - -function workerItems(worker: WorkerView): InboxItem[] { - const items: InboxItem[] = []; - const key = (condition: string) => `${worker.id}/${condition}`; - if (worker.connection === "disconnected" || worker.connection === "incompatible") { - items.push({ condition: worker.connection, key: key(worker.connection), worker }); - } - if (worker.drained) items.push({ condition: "drained", key: key("drained"), worker }); - if (worker.lastUpdateFailure !== undefined && worker.lastUpdateFailure !== null) { - items.push({ condition: "update failed", key: key("update-failed"), worker }); - } - const ramBudget = worker.capacity?.ramBudget; - if (ramBudget?.overLimit === true) { - items.push({ - condition: "over RAM budget", - key: key("ram"), - limitBytes: ramBudget.limitBytes, - usedBytes: ramBudget.usedBytes, - worker, - }); - } - return items; +export interface InboxList { + readonly items: readonly InboxItem[]; } -function deviceItems(worker: WorkerView): InboxItem[] { - return (worker.devices ?? []).flatMap((device): InboxItem[] => { - const key = (condition: string) => `${worker.id}/${device.id}/${condition}`; - if (device.state === "quarantined") { - return [{ condition: "quarantined", device, key: key("quarantined"), worker }]; - } - if (device.stalled === true) { - return [{ condition: "stalled", device, key: key("stalled"), worker }]; - } - if (device.state === "absent") { - return [{ condition: "absent", device, key: key("absent"), worker }]; - } - return []; - }); -} +type WorkerItem = Extract; const GROUPS: readonly InboxGroup[] = ["Broken", "Needs a look", "Heads-up"]; -export function inboxGroup(item: InboxItem): InboxGroup { - if (item.condition === "ending") return "Heads-up"; - return inboxTone(item.condition) === "error" ? "Broken" : "Needs a look"; -} - -// why: a heads-up is not a problem yet, so the count the sidebar and Overview show leaves it out. -export function problemCount(items: readonly InboxItem[]): number { - return items.filter((item) => inboxGroup(item) !== "Heads-up").length; -} - // why: the Inbox lists its groups in a fixed order, each keeping the order its items came in. export function inGroupOrder(items: readonly InboxItem[]): readonly InboxItem[] { - return GROUPS.flatMap((group) => items.filter((item) => inboxGroup(item) === group)); + return GROUPS.flatMap((group) => items.filter((item) => item.group === group)); } -// why: a lost worker or a quarantined device is broken; everything else on the list needs a look -// soon, which is the warning tone, the same split the status words use. -export function inboxTone(condition: InboxItem["condition"]): Tone { - return condition === "disconnected" || condition === "incompatible" || condition === "quarantined" - ? "error" - : "warn"; +// why: a broken item is the error tone; everything else on the list needs a look soon, which is the +// warning tone, the same split the status words use. +export function inboxTone(group: InboxGroup): Tone { + return group === "Broken" ? "error" : "warn"; } -export type InboxGroup = "Broken" | "Needs a look" | "Heads-up"; +export type InboxGroup = InboxItem["group"]; export type InboxFilter = "all" | InboxGroup; @@ -253,36 +110,54 @@ function minutesAndSeconds(ms: number): string { return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; } -export function inboxSubject(item: InboxItem): string { +// why: the daemon sends each time as the subject's own timestamp and the console counts from them +// against the daemon's clock, so the wait and the time left tick between reads. +function waitedMs(item: Extract, now: number): number { + return now - item.request.createdAt; +} + +function leftMs(item: Extract, now: number): number { + return item.lease.ttlDeadline - now; +} + +function deviceWorkerName(device: { + readonly workerId: string; + readonly workerLabel?: string | undefined; +}): string { + return device.workerLabel ?? device.workerId; +} + +export function inboxSubject(item: InboxItem, now: number): string { if (item.condition === "waiting") { - return `${item.request.requesterId} has waited ${formatDuration(item.waitedMs)}`; + return `${item.request.requesterId} has waited ${formatDuration(waitedMs(item, now))}`; } if (item.condition === "ending") { - const base = `${item.holder} · ${item.model} ends`; - return item.leftMs <= 0 ? `${base} now` : `${base} in ${minutesAndSeconds(item.leftMs)}`; + const base = `${item.lease.holder} · ${item.lease.model} ends`; + const left = leftMs(item, now); + return left <= 0 ? `${base} now` : `${base} in ${minutesAndSeconds(left)}`; } if (item.condition === "absent") { // why: the daemon names a reason for every absent phone; "not-present" is the plain fallback for a record that lacks one. return `${item.device.spec.model} is absent (${item.device.absentReason ?? "not-present"})`; } return "device" in item - ? `${item.device.spec.model} on ${workerName(item.worker)}` + ? `${item.device.spec.model} on ${deviceWorkerName(item.device)}` : workerName(item.worker); } function factsOf(item: InboxItem, now: number): readonly InboxFact[] { if (item.condition === "waiting") { return [ - { label: "Waited", value: formatDuration(item.waitedMs) }, - { label: "Device", value: requestedDevice(item.request.spec) }, + { label: "Waited", value: formatDuration(waitedMs(item, now)) }, + { label: "Device", value: describeRequestedDevice(item.request.spec) }, { label: "Request", value: item.request.id }, ]; } if (item.condition === "ending") { return [ - { label: "Time left", value: timeLeft(item.leftMs) }, - { label: "Device", value: item.model }, - { label: "Holder", value: item.holder }, + { label: "Time left", value: timeLeft(leftMs(item, now)) }, + { label: "Device", value: item.lease.model }, + { label: "Holder", value: item.lease.holder }, { label: "Lease", value: item.lease.id }, ]; } @@ -305,15 +180,15 @@ type ItemOf = InboxItem extends infer I : never : never; -type Say = { readonly [C in InboxItem["condition"]]: (item: ItemOf) => string }; +type Say = { readonly [C in InboxItem["condition"]]: (item: ItemOf, now: number) => string }; const SAY: Say = { - absent: (item) => `${inboxSubject(item)}: the phone cannot be reached.`, + absent: (item, now) => `${inboxSubject(item, now)}: the phone cannot be reached.`, disconnected: (item) => `${workerName(item.worker)} has lost its connection to the gateway.`, drained: (item) => `${workerName(item.worker)} is drained: it takes no new leases.`, - ending: (item) => - `${item.holder}'s lease on ${item.model} ${ - item.leftMs <= 0 ? "ends now" : `ends in ${minutesAndSeconds(item.leftMs)}` + ending: (item, now) => + `${item.lease.holder}'s lease on ${item.lease.model} ${ + leftMs(item, now) <= 0 ? "ends now" : `ends in ${minutesAndSeconds(leftMs(item, now))}` }.`, incompatible: (item) => `${workerName(item.worker)} runs a protocol this gateway cannot talk to.`, "over RAM budget": (item) => @@ -327,19 +202,19 @@ const SAY: Say = { ? "" : `The update of ${workerName(item.worker)} to ${failure.to} failed while ${failure.stage}: ${failure.message}. It runs ${failure.from} again.`; }, - waiting: (item) => - `${item.request.requesterId} has waited ${formatDuration(item.waitedMs)} for a device.`, + waiting: (item, now) => + `${item.request.requesterId} has waited ${formatDuration(waitedMs(item, now))} for a device.`, }; -function whatHappened(item: InboxItem): string { - return (SAY[item.condition] as (item: InboxItem) => string)(item); +function whatHappened(item: InboxItem, now: number): string { + return (SAY[item.condition] as (item: InboxItem, now: number) => string)(item, now); } function deviceFacts( item: Extract, now: number, ): readonly InboxFact[] { - const since = stateEnteredAt(item.device, item.worker); + const since = item.device.stateEnteredAt; return [ { label: "Device", value: item.device.id }, { label: "State", value: item.device.state }, @@ -377,7 +252,7 @@ function causeFacts(device: { ]; } -function updateFailureFacts(worker: WorkerView, now: number): readonly InboxFact[] { +function updateFailureFacts(worker: WorkerItem["worker"], now: number): readonly InboxFact[] { const failure = worker.lastUpdateFailure; return failure === undefined || failure === null ? [] @@ -404,7 +279,7 @@ function workerFacts( return [{ label: "Last seen", value: `${formatDuration(now - worker.lastSeenAt)} ago` }]; } if (item.condition === "drained") { - return [{ label: "Leases still held", value: String(worker.leases?.length ?? 0) }]; + return [{ label: "Leases still held", value: String(worker.heldLeases) }]; } if (item.condition === "update failed") return updateFailureFacts(worker, now); const { protocol } = worker; @@ -430,7 +305,7 @@ function eventsOfItem(item: InboxItem, events: readonly ConsoleEvent[]): readonl } return events.filter( (event) => - workerIdOf(event) === item.worker.id || + workerIdOf(event) === ("device" in item ? item.device.workerId : item.worker.id) || ("device" in item && payloadField(event, "deviceId") === item.device.id), ); } @@ -445,11 +320,18 @@ function aboutOf(item: InboxItem): InboxDetail["about"] { } if (item.condition === "ending") { return { - name: `${item.holder} · ${item.model}`, + name: `${item.lease.holder} · ${item.lease.model}`, open: "Open lease", to: leasePath(item.lease.id), }; } + if ("device" in item) { + return { + name: deviceWorkerName(item.device), + open: "Open worker", + to: workerPath(item.device.workerId), + }; + } return { name: workerName(item.worker), open: "Open worker", to: workerPath(item.worker.id) }; } @@ -489,8 +371,8 @@ function detailOf( facts: factsOf(item, now), key: item.key, title: titleOf(item), - tone: inboxTone(item.condition), - what: whatHappened(item), + tone: inboxTone(item.group), + what: whatHappened(item, now), word: item.condition, }; } @@ -498,21 +380,19 @@ function detailOf( // why: the rows and the drawer's detail are worked out together from the same items, so they // never disagree. export function inboxPageOf(input: { + readonly items: readonly InboxItem[]; readonly workers: readonly WorkerView[]; readonly events: readonly ConsoleEvent[]; readonly now: number; readonly key: string | undefined; - readonly requests?: readonly WaitingRequest[] | undefined; - readonly leases?: readonly LeaseRecord[] | undefined; - readonly tokens?: readonly TokenRecord[] | undefined; }): InboxPage { - const items = inboxItems(input.workers, input); + const { items, now } = input; const rows = inGroupOrder(items).map((item) => ({ - group: inboxGroup(item), + group: item.group, key: item.key, - subject: inboxSubject(item), + subject: inboxSubject(item, now), to: inboxPath(item.key), - tone: inboxTone(item.condition), + tone: inboxTone(item.group), word: item.condition, })); const named = input.key !== undefined; diff --git a/ui/src/model/leases.test.ts b/ui/src/model/leases.test.ts index 94812a411..628d97157 100644 --- a/ui/src/model/leases.test.ts +++ b/ui/src/model/leases.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; +import { holderOf } from "../../../src/contract/inbox"; import { eventsOfLease, filterLeaseRows, - holderOf, type LeaseDetails, type LeaseRecord, leaseInspection, diff --git a/ui/src/model/leases.ts b/ui/src/model/leases.ts index 72c63a426..f9180c83e 100644 --- a/ui/src/model/leases.ts +++ b/ui/src/model/leases.ts @@ -1,6 +1,13 @@ import type { z } from "zod"; import type { statusLeaseSchema, tokenRecordSchema } from "../../../src/contract/schemas"; +import { + deviceOfLease, + holderName, + holderOf, + isExpiring, + workerOfLease, +} from "../../../src/contract/inbox"; import { defined } from "./defined"; import { type ConsoleEvent, textField, workerIdOf } from "./events"; import { formatDuration } from "./time"; @@ -39,42 +46,6 @@ export interface Holder { readonly label?: string; } -// why: over HTTP a requester's id is its token's id, so a token with that id names it; a requester -// no token matches, such as an agent on the local socket, is its id alone. -export function holderOf(requesterId: string, tokens: readonly TokenRecord[]): Holder { - const id = agentIdOf(requesterId); - const label = tokens.find((token) => token.id === id)?.label; - return label === undefined ? { id } : { id, label }; -} - -const GATEWAY_REQUESTER = /^gw:[^:]+:(.+)$/; - -// why: a gateway hands a worker its agents' requests under `gw::`, so a -// worker's own view names the agent behind that prefix; the console shows the agent, as the -// gateway's lease list does. -export function agentIdOf(requesterId: string): string { - return GATEWAY_REQUESTER.exec(requesterId)?.[1] ?? requesterId; -} - -// why: a holder's title is its token's label, or its id where no label names it. -function holderTitle(holder: Holder): string { - return holder.label ?? holder.id; -} - -export function holderName(requesterId: string, tokens: readonly TokenRecord[]): string { - return holderTitle(holderOf(requesterId, tokens)); -} - -// why: a single host names no worker on its leases, and every lease there is on its one worker, the -// host itself. -function workerOfLease( - lease: { readonly workerId?: string | undefined }, - workers: readonly WorkerView[], -): WorkerView | undefined { - if (lease.workerId === undefined) return workers.length === 1 ? workers[0] : undefined; - return workers.find((worker) => worker.id === lease.workerId); -} - export function leasesOnWorker( leases: readonly LeaseRecord[], workerId: string, @@ -99,14 +70,7 @@ function compare(a: string, b: string): number { return a < b ? -1 : 1; } -export function deviceOfLease( - lease: LeaseRecord, - workers: readonly WorkerView[], -): WorkerDevice | undefined { - return workerOfLease(lease, workers)?.devices?.find((device) => device.id === lease.deviceId); -} - -function workerNameOfLease( +export function workerNameOfLease( lease: { readonly workerId?: string | undefined; readonly worker?: { readonly label?: string | undefined } | undefined; @@ -133,14 +97,6 @@ export const LEASE_FILTERS: readonly { readonly value: LeaseFilter; readonly lab { label: "Android", value: "android" }, ]; -// why: a lease with under 5 minutes left is about to end, so the list, the lease drawer, the Overview and the -// Inbox warn by the same rule. -const EXPIRING_UNDER_MS = 5 * 60_000; - -export function isExpiring(deadline: number, now: number): boolean { - return deadline - now < EXPIRING_UNDER_MS; -} - function percentLeft(deadline: number, now: number, ttlMs: number): number { return ttlMs <= 0 ? 0 : Math.max(0, Math.min(100, ((deadline - now) / ttlMs) * 100)); } @@ -328,7 +284,7 @@ function recordFacts( holder, percent: percentLeft(deadline, now, record.ttlMs), renewed: `${formatDuration(now - record.lastRenewedAt)} ago`, - title: holderTitle(holder), + title: holderName(record.requesterId, tokens), ...(record.ownerId === record.requesterId ? {} : { ownerId: record.ownerId }), }; } diff --git a/ui/src/model/overview.test.ts b/ui/src/model/overview.test.ts index ed3e7aed8..de3ee7189 100644 --- a/ui/src/model/overview.test.ts +++ b/ui/src/model/overview.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { inboxItems } from "../../../src/contract/inbox"; import { figuresOf } from "./figures"; import { overviewOf } from "./overview"; import { statsOf } from "./test-support"; @@ -135,15 +136,17 @@ describe("the overview", () => { ttlDeadline: NOW + 100_000, ttlMs: 900_000, }; + const workers = [ + ...fleet, + worker("down-2", [], { connection: "disconnected" }), + worker("down-3", [], { connection: "disconnected" }), + ]; const { inbox } = overviewOf({ + items: inboxItems(workers, { leases: [lease], now: NOW }), leases: [lease], now: NOW, requests: [], - workers: [ - ...fleet, - worker("down-2", [], { connection: "disconnected" }), - worker("down-3", [], { connection: "disconnected" }), - ], + workers, }); expect(inbox.problems).toBe(5); diff --git a/ui/src/model/overview.ts b/ui/src/model/overview.ts index 16f4301cf..6cb2041e4 100644 --- a/ui/src/model/overview.ts +++ b/ui/src/model/overview.ts @@ -1,13 +1,7 @@ -import { - inboxGroup, - type InboxContext, - inboxItems, - inGroupOrder, - inboxSubject, - inboxTone, -} from "./inbox"; +import { type InboxItem, inGroupOrder, inboxSubject, inboxTone } from "./inbox"; import { platformSlots } from "./fleet"; -import { isExpiring, type LeaseRecord, type TokenRecord } from "./leases"; +import { isExpiring } from "../../../src/contract/inbox"; +import type { LeaseRecord } from "./leases"; import type { FiguresState } from "./figures"; import { formatCount } from "./paging"; import { inboxPath } from "./paths"; @@ -33,8 +27,6 @@ export interface OverviewItem { } export interface OverviewInbox { - // why: a read that failed leaves the Inbox unchecked there, so the page never says it is clear. - readonly couldNotCheck: readonly string[]; readonly problems: number; readonly needsYou: readonly OverviewItem[]; } @@ -178,56 +170,35 @@ function endingSoon(leases: readonly LeaseRecord[] | undefined, now: number): Ov } // why: Needs you lists problems only, in the Inbox's own order, so its first rows are the Inbox's. -function inboxOf(input: { - readonly workers: readonly WorkerView[]; - readonly couldNotCheck: readonly string[]; - readonly context: InboxContext; -}): OverviewInbox { - const { context, couldNotCheck, workers } = input; - const problems = inGroupOrder(inboxItems(workers, context)).filter( - (item) => inboxGroup(item) !== "Heads-up", - ); +function inboxOf(items: readonly InboxItem[], now: number): OverviewInbox { + const problems = inGroupOrder(items).filter((item) => item.group !== "Heads-up"); return { needsYou: problems.slice(0, NEEDS_YOU_ROWS).map((item) => ({ key: item.key, - subject: inboxSubject(item), + subject: inboxSubject(item, now), to: inboxPath(item.key), - tone: inboxTone(item.condition), + tone: inboxTone(item.group), word: item.condition, })), - couldNotCheck, problems: problems.length, }; } -// why: the overview reads three routes the other pages read too, and every figure on it is worked +// why: the overview reads routes the other pages read too, and every figure on it is worked // out here from those, so a figure on the overview and the same figure on its page always agree. export function overviewOf(input: { readonly workers: readonly WorkerView[]; readonly requests: readonly WaitingRequest[]; + readonly items?: readonly InboxItem[]; readonly leases?: readonly LeaseRecord[] | undefined; - readonly tokens?: readonly TokenRecord[] | undefined; readonly figures?: FiguresState; - readonly couldNotCheck?: readonly string[]; readonly now: number; }): Overview { - const { - couldNotCheck = [], - figures = { kind: "loading" }, - leases, - now, - requests, - tokens, - workers, - } = input; + const { figures = { kind: "loading" }, items = [], leases, now, requests, workers } = input; return { capacity: [platformCapacity(workers, "ios"), platformCapacity(workers, "android")], ending: endingSoon(leases, now), - inbox: inboxOf({ - context: { leases, now, requests, tokens }, - couldNotCheck, - workers, - }), + inbox: inboxOf(items, now), figures, tiles: tiles(workers, requests, now), ...(figures.kind === "ready" ? { wait: figures.data.wait } : {}), diff --git a/ui/src/model/stale-keys.ts b/ui/src/model/stale-keys.ts index b1b762cd0..9f52b99f0 100644 --- a/ui/src/model/stale-keys.ts +++ b/ui/src/model/stale-keys.ts @@ -1,9 +1,10 @@ // why: a key is the front of a query key; `["lease"]` stands for every `["lease", id]`. export type StaleKey = readonly string[]; -const LEASES: readonly StaleKey[] = [["leases"], ["workers"], ["lease"]]; -const REQUESTS: readonly StaleKey[] = [["lease-requests"]]; -const WORKERS: readonly StaleKey[] = [["workers"]]; +// why: the Inbox is made from the workers, leases and waiting requests, so whatever marks one of them marks it. +const LEASES: readonly StaleKey[] = [["leases"], ["workers"], ["lease"], ["inbox"]]; +const REQUESTS: readonly StaleKey[] = [["lease-requests"], ["inbox"]]; +const WORKERS: readonly StaleKey[] = [["workers"], ["inbox"]]; const NONE: readonly StaleKey[] = []; function hasLeaseId(payload: unknown): boolean { @@ -30,7 +31,8 @@ export function staleKeys(name: string, payload: unknown): readonly StaleKey[] { default: } if (name.startsWith("request.")) return REQUESTS; - if (name.startsWith("device.")) return hasLeaseId(payload) ? [["workers"], ["lease"]] : WORKERS; + if (name.startsWith("device.")) + return hasLeaseId(payload) ? [["workers"], ["lease"], ["inbox"]] : WORKERS; if ( name.startsWith("worker.") || name.startsWith("component.") || diff --git a/ui/src/model/usage.ts b/ui/src/model/usage.ts index 93c507423..766ab7278 100644 --- a/ui/src/model/usage.ts +++ b/ui/src/model/usage.ts @@ -8,7 +8,7 @@ import { waitTile, } from "./figures"; import { formatBytes } from "./load"; -import { agentIdOf } from "./leases"; +import { agentIdOf } from "../../../src/contract/inbox"; import { formatCount } from "./paging"; import { type ConsoleRange, rangeUnit, writeRange } from "./range"; import { formatDuration } from "./time"; diff --git a/ui/src/model/versions.ts b/ui/src/model/versions.ts index 2fea3303d..a784d1155 100644 --- a/ui/src/model/versions.ts +++ b/ui/src/model/versions.ts @@ -10,15 +10,25 @@ export interface UpdateFacts { readonly leased: number; } -export function updateFactsOf(worker: WorkerView): UpdateFacts { +export function updateFactsFrom( + worker: Pick, + leased: number, +): UpdateFacts { return { latestVersion: worker.latestVersion ?? null, - leased: (worker.devices ?? []).filter((device) => device.state === "leased").length, + leased, update: worker.update ?? null, ...(worker.version === undefined ? {} : { version: worker.version }), }; } +export function updateFactsOf(worker: WorkerView): UpdateFacts { + return updateFactsFrom( + worker, + (worker.devices ?? []).filter((device) => device.state === "leased").length, + ); +} + // why: the "newer" rule is the contract's (`isNewerVersion`); the console never compares itself. export function isBehind(facts: UpdateFacts): boolean { return ( diff --git a/ui/src/model/waiting.test.ts b/ui/src/model/waiting.test.ts index 3572408f2..c1f7a80b9 100644 --- a/ui/src/model/waiting.test.ts +++ b/ui/src/model/waiting.test.ts @@ -1,13 +1,8 @@ import { describe, expect, it } from "vitest"; import { waitingRequestSchema } from "../../../src/contract/index"; -import { - chooseWaiting, - requestedDevice, - type WaitingRequest, - type WaitingRow, - waitingTable, -} from "./waiting"; +import { describeRequestedDevice as requestedDevice } from "../../../src/contract/inbox"; +import { chooseWaiting, type WaitingRequest, type WaitingRow, waitingTable } from "./waiting"; import type { WorkerView } from "./workers"; const NOW = Date.parse("2026-10-02T12:00:00Z"); diff --git a/ui/src/model/waiting.ts b/ui/src/model/waiting.ts index eaffce18e..5455bb613 100644 --- a/ui/src/model/waiting.ts +++ b/ui/src/model/waiting.ts @@ -1,10 +1,11 @@ import type { z } from "zod"; import type { waitingRequestSchema } from "../../../src/contract/schemas"; +import { describeRequestedDevice, platformName } from "../../../src/contract/inbox"; import { itemPath } from "./paths"; import { formatDuration } from "./time"; import { type Toned, waitingStageStatus } from "./tones"; -import { platformName, type WorkerView, workerName, workerPath } from "./workers"; +import { type WorkerView, workerName, workerPath } from "./workers"; export type WaitingRequest = z.infer; @@ -12,19 +13,6 @@ export interface WaitingList { readonly requests: readonly WaitingRequest[]; } -export function requestedDevice(spec: WaitingRequest["spec"]): string { - return [ - [platformName(spec.platform), spec.model ?? spec.class] - .filter((part) => part !== undefined) - .join(" "), - spec.osVersion === undefined ? undefined : `runtime ${spec.osVersion}`, - spec.mode === undefined ? undefined : `mode ${spec.mode}`, - spec.imageTag === undefined ? undefined : `image tag ${spec.imageTag}`, - ] - .filter((part) => part !== undefined) - .join(", "); -} - // why: a single host names no worker on its requests, so the request is found in the host's view by // its id and requester. A gateway's own request never matches a worker's: its id comes from the // gateway, and a worker lists the copy the gateway sent it under the gateway's requester id. @@ -179,7 +167,7 @@ export function waitingTable( const why = whyOf(request); return { created: createdAtOf(request.createdAt), - device: requestedDevice(request.spec), + device: describeRequestedDevice(request.spec), id: request.id, key: `${request.workerId ?? ""}/${request.id}`, physicalQueue: request.spec.physical === true, diff --git a/ui/src/model/worker-page.ts b/ui/src/model/worker-page.ts index e62fc7d75..c4f3df699 100644 --- a/ui/src/model/worker-page.ts +++ b/ui/src/model/worker-page.ts @@ -10,7 +10,8 @@ import { } from "./fleet"; import { formatBusyPercent, formatBytes, formatCores, loadGauges } from "./load"; import { levelOf } from "./load-thresholds"; -import { holderOf, leasePath, type TokenRecord } from "./leases"; +import { holderOf, platformName, stateEnteredAt } from "../../../src/contract/inbox"; +import { leasePath, type TokenRecord } from "./leases"; import { devicePath } from "./paths"; import { formatDuration } from "./time"; import { type UpdateFacts, updateFactsOf, versionLine } from "./versions"; @@ -25,8 +26,6 @@ import { } from "./tones"; import { capacityByPlatform, - platformName, - stateEnteredAt, type WarmPoolRow, warmPoolRows, type WorkerDevice, diff --git a/ui/src/model/workers.ts b/ui/src/model/workers.ts index cf1168d19..012549068 100644 --- a/ui/src/model/workers.ts +++ b/ui/src/model/workers.ts @@ -9,7 +9,10 @@ export interface WorkerList { readonly workers: readonly WorkerView[]; } -export function workerName(worker: WorkerView): string { +export function workerName(worker: { + readonly id: string; + readonly label?: string | undefined; +}): string { return worker.label ?? worker.id; } @@ -18,15 +21,6 @@ export function workerPath(id: string): `/${string}` { return `/fleet/${encodeURIComponent(id)}`; } -const PLATFORM_NAMES: Readonly>> = { - android: "Android", - ios: "iOS", -}; - -export function platformName(platform: string): string { - return PLATFORM_NAMES[platform] ?? platform; -} - export function capacityByPlatform( worker: WorkerView, ): readonly { readonly platform: string; readonly running: number; readonly limit: number }[] { @@ -52,16 +46,6 @@ export function leasesHeld(workers: readonly WorkerView[]): number | undefined { return workers.reduce((sum, worker) => sum + (worker.leases?.length ?? 0), 0); } -// why: the age is read only from a connected worker: a gateway moves `lastSeenAt` when a worker -// disconnects, without reading its devices again. -export function stateEnteredAt(device: WorkerDevice, worker: WorkerView): number | undefined { - if (device.state === "leased") { - return worker.leases?.find((lease) => lease.deviceId === device.id)?.grantedAt; - } - if (device.transitionAgeMs === undefined || worker.connection !== "connected") return undefined; - return worker.lastSeenAt - device.transitionAgeMs; -} - export type WarmTarget = NonNullable["targets"][number]; export type WarmPoolRow = WarmTarget & { readonly key: string }; diff --git a/ui/src/screens/inbox/index.test.tsx b/ui/src/screens/inbox/index.test.tsx index 0ab0dfc66..aa89b4013 100644 --- a/ui/src/screens/inbox/index.test.tsx +++ b/ui/src/screens/inbox/index.test.tsx @@ -1,6 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { inboxItems } from "../../../../src/contract/inbox"; import { inboxPageOf } from "../../model/inbox"; import type { WorkerView } from "../../model/workers"; import { InboxBody } from "."; @@ -21,7 +22,13 @@ function worker(overrides: Partial = {}): WorkerView { } function render(workers: readonly WorkerView[]): string { - const page = inboxPageOf({ events: [], key: undefined, now: NOW, workers }); + const page = inboxPageOf({ + events: [], + items: inboxItems(workers), + key: undefined, + now: NOW, + workers, + }); return renderToStaticMarkup(); } @@ -30,15 +37,6 @@ describe("the Inbox screen", () => { expect(render([worker()])).toContain("Nothing needs you."); }); - it("says what it could not check, and not Nothing needs you, when it has no rows", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain("Could not check: waiting requests"); - expect(html).not.toContain("Nothing needs you"); - }); - it("links each row to its item, with its condition as its name, under its group", () => { const html = render([ worker({ connection: "disconnected", id: "wrk_a", label: "mac-mini-1" }), diff --git a/ui/src/screens/inbox/index.tsx b/ui/src/screens/inbox/index.tsx index 89a05b9ad..6e9058d73 100644 --- a/ui/src/screens/inbox/index.tsx +++ b/ui/src/screens/inbox/index.tsx @@ -33,7 +33,7 @@ import { const GROUP_ORDER = ["Broken", "Needs a look", "Heads-up"] as const; export function InboxScreen() { - const { choose, couldNotCheck, filter, rows, urlId, view } = useInbox(); + const { choose, filter, rows, urlId, view } = useInbox(); const { close, fromList } = useInboxDrawer(urlId); const actionsOf = useInboxActions(); const drawerActions = (row: InboxRow | undefined) => @@ -60,12 +60,7 @@ export function InboxScreen() { {(page) => ( <> - + {urlId === undefined ? null : ( InboxRowActions; }) { - const { actionsOf, couldNotCheck = [], filter, rows } = props; - const unchecked = - couldNotCheck.length === 0 ? null : ( -

{`Could not check: ${couldNotCheck.join(", ")}`}

- ); + const { actionsOf, filter, rows } = props; if (rows.length === 0) { - // why: an Inbox that could not read everything never claims nothing needs you. - if (unchecked !== null) return unchecked; return (

@@ -114,7 +102,6 @@ export function InboxBody(props: { } return ( <> - {unchecked} {GROUP_ORDER.map((group) => { const inGroup = rows.filter((row) => row.group === group); return inGroup.length === 0 ? null : ( diff --git a/ui/src/screens/overview/index.tsx b/ui/src/screens/overview/index.tsx index 378ba653a..71c37af0b 100644 --- a/ui/src/screens/overview/index.tsx +++ b/ui/src/screens/overview/index.tsx @@ -142,14 +142,10 @@ function PlatformBar({ platform }: { readonly platform: PlatformCapacity }) { } function NeedsYou({ inbox }: { readonly inbox: OverviewInbox }) { - const unchecked = - inbox.couldNotCheck.length === 0 ? null : ( -

{`Could not check: ${inbox.couldNotCheck.join(", ")}`}

- ); if (inbox.problems === 0) { return ( - {unchecked ??

Nothing needs you

} +

Nothing needs you

); } @@ -170,7 +166,6 @@ function NeedsYou({ inbox }: { readonly inbox: OverviewInbox }) { {`All in Inbox (${inbox.problems})`}

- {unchecked}
); } diff --git a/ui/src/screens/overview/overview.test.tsx b/ui/src/screens/overview/overview.test.tsx index ba3205d40..861f84ba6 100644 --- a/ui/src/screens/overview/overview.test.tsx +++ b/ui/src/screens/overview/overview.test.tsx @@ -18,7 +18,7 @@ const quiet: Overview = { figures: { kind: "loading" }, capacity: [], ending: { label: "Ending soon", value: "0" }, - inbox: { couldNotCheck: [], needsYou: [], problems: 0 }, + inbox: { needsYou: [], problems: 0 }, tiles: [], workers: [], }; @@ -38,7 +38,6 @@ describe("the overview screen", () => { overview={{ ...quiet, inbox: { - couldNotCheck: [], needsYou: [ { key: "w1/disconnected", @@ -60,52 +59,6 @@ describe("the overview screen", () => { expect(text(shown)).not.toContain("Nothing needs you"); }); - it("says Could not check, and not Nothing needs you, when a read failed and no problem is known", () => { - const shown = text( - inConsole( - , - ), - ); - - expect(shown).toContain("Could not check: waiting requests, leases"); - expect(shown).not.toContain("Nothing needs you"); - }); - - it("shows the Needs you panel and the Could not check line together when a read failed and a problem is known", () => { - const shown = text( - inConsole( - , - ), - ); - - expect(shown).toContain("Needs you"); - expect(shown).toContain("All in Inbox (1)"); - expect(shown).toContain("Could not check: leases"); - expect(shown).not.toContain("Nothing needs you"); - }); - it("links the workers table to Fleet and says 2 workers not read yet in the plural", () => { const shown = inConsole( , diff --git a/ui/src/state/inbox.test.ts b/ui/src/state/inbox.test.ts deleted file mode 100644 index 19248993f..000000000 --- a/ui/src/state/inbox.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ApiError } from "../data/api"; -import type { LeaseList } from "../model/leases"; -import type { WorkerList } from "../model/workers"; -import { inboxReadsOf } from "./inbox"; - -const NO_LEASES = { leases: [] } as unknown as LeaseList; -const NO_WORKERS = { workers: [] } as unknown as WorkerList; -const DOWN = new ApiError(500, undefined, "down"); - -describe("what the Inbox could not check", () => { - it("names nothing when every read answered", () => { - const reads = inboxReadsOf( - { data: { requests: [] } }, - { data: NO_LEASES }, - {}, - { data: NO_WORKERS }, - ); - - expect(reads.couldNotCheck).toEqual([]); - }); - - it("names the waiting requests when that read failed before any answer, and counts none", () => { - const reads = inboxReadsOf({ error: DOWN }, { data: NO_LEASES }, {}, { data: NO_WORKERS }); - - expect(reads.couldNotCheck).toEqual(["waiting requests"]); - expect(reads.waiting).toEqual({ data: { requests: [] }, kind: "ready" }); - }); - - it.each([ - ["waiting requests", "waiting"], - ["workers", "workers"], - ] as const)( - "names the %s when that read failed after an earlier answer, and keeps the answer", - (name, field) => { - const held = field === "waiting" ? { requests: [] } : NO_WORKERS; - const reads = inboxReadsOf( - field === "waiting" ? { data: { requests: [] }, error: DOWN } : { data: { requests: [] } }, - { data: NO_LEASES }, - {}, - field === "workers" ? { data: NO_WORKERS, error: DOWN } : { data: NO_WORKERS }, - ); - - expect(reads.couldNotCheck).toEqual([name]); - expect(reads[field]).toEqual({ data: held, kind: "ready", refusal: "down" }); - }, - ); - - it("names the leases when that read failed, before any answer and after one", () => { - expect( - inboxReadsOf({ data: { requests: [] } }, { error: DOWN }, {}, { data: NO_WORKERS }) - .couldNotCheck, - ).toEqual(["leases"]); - expect( - inboxReadsOf( - { data: { requests: [] } }, - { data: NO_LEASES, error: DOWN }, - {}, - { data: NO_WORKERS }, - ).couldNotCheck, - ).toEqual(["leases"]); - }); - - it("names both, waiting requests first", () => { - expect( - inboxReadsOf({ error: DOWN }, { error: DOWN }, {}, { data: NO_WORKERS }).couldNotCheck, - ).toEqual(["waiting requests", "leases"]); - }); - - it("names nothing for a daemon that keeps no queue of its own", () => { - const unsupported = new ApiError(501, "UNSUPPORTED_IN_WORKER_MODE", "no queue here"); - const reads = inboxReadsOf( - { error: unsupported }, - { data: NO_LEASES }, - {}, - { data: NO_WORKERS }, - ); - - expect(reads.couldNotCheck).toEqual([]); - expect(reads.waiting).toEqual({ data: { requests: [] }, kind: "ready" }); - }); - - it("counts no leases when that read failed after an earlier answer", () => { - const held = { leases: [{ id: "held" }] } as unknown as LeaseList; - const reads = inboxReadsOf( - { data: { requests: [] } }, - { data: held, error: DOWN }, - {}, - { data: NO_WORKERS }, - ); - - expect(reads.leases).toBeUndefined(); - }); -}); diff --git a/ui/src/state/inbox.ts b/ui/src/state/inbox.ts index a8c4a3abf..9d43f746d 100644 --- a/ui/src/state/inbox.ts +++ b/ui/src/state/inbox.ts @@ -1,20 +1,17 @@ import { useState } from "react"; -import type { ResourceState } from "../data/live/connection"; import type { MenuEntry } from "../ds"; import type { LoadView } from "../ds/loaded"; import { inboxActions } from "../model/actions"; import { filterInboxRows, - inboxItems, type InboxFilter, + type InboxList, type InboxPage, inboxPageOf, type InboxRow, } from "../model/inbox"; -import type { LeaseList, TokenList } from "../model/leases"; import { itemIdFrom } from "../model/paths"; -import type { WaitingList } from "../model/waiting"; import type { WorkerList } from "../model/workers"; import { type ListDrawerState, useListDrawer } from "./drawer"; import { useRecentEvents } from "./events"; @@ -26,66 +23,18 @@ import { usePath } from "./router"; export type { InboxEvent, InboxPage, InboxRow } from "../model/inbox"; export { INBOX_FILTERS, type InboxFilter } from "../model/inbox"; -export interface InboxReads { - readonly waiting: LoadView; - readonly workers: LoadView; - readonly leases: LeaseList["leases"] | undefined; - readonly tokens: TokenList["tokens"] | undefined; - readonly couldNotCheck: readonly string[]; -} - -// why: a daemon that keeps no queue of its own answers 501; the Inbox then counts no waiting -// requests instead of hiding the fleet behind "not available". A read that failed is not that, -// even when an earlier answer is still held: the rest still shows, and the page says what could -// not be checked. A read that failed after an answer counts nothing from it either: the held -// answer is stale, and a figure from it would read as known. -export function inboxReadsOf( - asked: ResourceState, - leases: ResourceState, - tokens: ResourceState, - workerState: ResourceState, -): InboxReads { - const waiting = loadView(asked); - const workers = loadView(workerState); - const failed = - waiting.kind === "refused" || (waiting.kind === "ready" && waiting.refusal !== undefined); - return { - couldNotCheck: [ - ...(failed ? ["waiting requests"] : []), - ...(leases.error === undefined ? [] : ["leases"]), - ...(workers.kind === "ready" && workers.refusal !== undefined ? ["workers"] : []), - ], - leases: leases.error === undefined ? leases.data?.leases : undefined, - tokens: tokens.data?.tokens, - waiting: - waiting.kind === "unsupported" || waiting.kind === "refused" - ? { data: { requests: [] }, kind: "ready" } - : waiting, - workers, - }; -} - -export function useInboxReads(): InboxReads { - return inboxReadsOf( - useRouteQuery("/v1/lease-requests"), - useRouteQuery("/v1/leases"), - useRouteQuery("/v1/tokens"), - useRouteQuery("/v1/workers"), - ); -} - export interface Inbox { readonly view: LoadView; readonly filter: InboxFilter; readonly choose: (filter: InboxFilter) => void; readonly rows: (page: InboxPage) => readonly InboxRow[]; - readonly couldNotCheck: readonly string[]; // why: the key in the URL stands even before the list has loaded, so the drawer can ask for it. readonly urlId: string | undefined; } export function useInbox(): Inbox { - const reads = useInboxReads(); + const items = loadView(useRouteQuery("/v1/inbox")); + const workers = loadView(useRouteQuery("/v1/workers")); // why: the events only fill the drawer's list, so a feed still loading shows the page with // none rather than holding back the list. const feed = useRecentEvents(); @@ -93,21 +42,18 @@ export function useInbox(): Inbox { const urlId = itemIdFrom("/inbox", usePath()); const [filter, choose] = useState("all"); const view = mapView( - bothViews(bothViews(reads.workers, reads.waiting), { data: feed.data ?? [], kind: "ready" }), - ([[list, waiting], events]) => + bothViews(bothViews(items, workers), { data: feed.data ?? [], kind: "ready" }), + ([[inbox, list], events]) => inboxPageOf({ events, + items: inbox.items, key: urlId, - leases: reads.leases, now: now.server, - requests: waiting.requests, - tokens: reads.tokens, workers: list.workers, }), ); return { choose, - couldNotCheck: reads.couldNotCheck, filter, rows: (page) => filterInboxRows(page.rows, filter), urlId, @@ -127,23 +73,14 @@ export interface InboxRowActions { // why: an Inbox row reads the one list of actions its item offers (ADR 0037 §3); only a drained // worker's row has a fix button. export function useInboxActions(): (row: InboxRow) => InboxRowActions { - const reads = useInboxReads(); + const items = useRouteQuery("/v1/inbox").data?.items ?? []; const { entries } = useActions(); const mode = useDaemonMode(); - const now = useNow(); - const workers = reads.workers.kind === "ready" ? reads.workers.data.workers : []; - const waiting = reads.waiting.kind === "ready" ? reads.waiting.data.requests : []; - const tokens = reads.tokens ?? []; - const items = inboxItems(workers, { - leases: reads.leases, - now: now.server, - requests: waiting, - tokens, - }); + const workers = useRouteQuery("/v1/workers").data?.workers ?? []; return (row) => { const item = items.find((candidate) => candidate.key === row.key); if (item === undefined) return { menu: [] }; - const actions = inboxActions(item, { mode, now: now.server, tokens, workers }); + const actions = inboxActions(item, { mode, workers }); const [fix] = actions.fix === undefined ? [] : entries([actions.fix]); return { menu: entries(actions.menu), ...(fix === undefined ? {} : { fix }) }; }; diff --git a/ui/src/state/overview.ts b/ui/src/state/overview.ts index 9886a82e1..cbd557d6c 100644 --- a/ui/src/state/overview.ts +++ b/ui/src/state/overview.ts @@ -1,27 +1,39 @@ import type { LoadView } from "../ds/loaded"; +import type { InboxList } from "../model/inbox"; +import type { LeaseList } from "../model/leases"; import { overviewOf, type Overview } from "../model/overview"; import type { ConsoleRange } from "../model/range"; +import type { WaitingList } from "../model/waiting"; +import type { WorkerList } from "../model/workers"; import { useFigures } from "./figures"; -import { useNow } from "./live"; -import { useInboxReads } from "./inbox"; -import { bothViews, mapView } from "./resource"; +import { useNow, useRouteQuery } from "./live"; +import { bothViews, loadView, mapView } from "./resource"; export type { Overview, OverviewInbox, PlatformCapacity } from "../model/overview"; export function useOverview(range: ConsoleRange): LoadView { - const { couldNotCheck, leases, tokens, waiting, workers } = useInboxReads(); + const workers = loadView(useRouteQuery("/v1/workers")); + const inbox = loadView(useRouteQuery("/v1/inbox")); + const asked = loadView(useRouteQuery("/v1/lease-requests")); + const leases = useRouteQuery("/v1/leases"); const figures = useFigures(range); const now = useNow(); - const reads = bothViews(workers, waiting); - return mapView(reads, ([workerList, waitingList]) => - overviewOf({ - couldNotCheck, - figures, - leases, - now: now.server, - requests: waitingList.requests, - tokens, - workers: workerList.workers, - }), + // why: a daemon that keeps no queue of its own answers 501, and the Waiting tile then counts none + // instead of hiding the fleet behind "not available". + const waiting: LoadView = + asked.kind === "unsupported" || asked.kind === "refused" + ? { data: { requests: [] }, kind: "ready" } + : asked; + return mapView( + bothViews(bothViews(workers, waiting), inbox), + ([[workerList, waitingList], inboxList]) => + overviewOf({ + figures, + items: inboxList.items, + leases: leases.error === undefined ? leases.data?.leases : undefined, + now: now.server, + requests: waitingList.requests, + workers: workerList.workers, + }), ); } diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index 5a385c328..fda91610d 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -46,6 +46,7 @@ describe("the query layer", () => { ["/v1/leases", 10_000], ["/v1/leases/l_1", 10_000], ["/v1/workers", 15_000], + ["/v1/inbox", 10_000], ["/v1/stats?since=1h", 60_000], ] as const; for (const [path, every] of intervals) { @@ -158,29 +159,30 @@ describe("events mark keys stale", () => { ...LEASES, "/v1/lease-requests", "/v1/workers", + "/v1/inbox", "/v1/stats?since=1h", "/v1/tokens", ] as const; // why: each row is an event, its payload, and exactly the routes it marks. const rows: readonly (readonly [string, Record, readonly string[]])[] = [ - ["lease.granted", {}, [...LEASES, "/v1/workers"]], - ["lease.released", {}, [...LEASES, "/v1/workers"]], - ["lease.expired", {}, [...LEASES, "/v1/workers"]], - ["lease.requested", {}, ["/v1/lease-requests"]], - ["lease.queued", {}, ["/v1/lease-requests"]], - ["lease.rejected", {}, ["/v1/lease-requests"]], - ["lease.declined", {}, ["/v1/lease-requests"]], - ["request.granted", {}, ["/v1/lease-requests"]], - ["request.dispatched", {}, ["/v1/lease-requests"]], - ["queue.changed", {}, ["/v1/lease-requests"]], + ["lease.granted", {}, [...LEASES, "/v1/workers", "/v1/inbox"]], + ["lease.released", {}, [...LEASES, "/v1/workers", "/v1/inbox"]], + ["lease.expired", {}, [...LEASES, "/v1/workers", "/v1/inbox"]], + ["lease.requested", {}, ["/v1/lease-requests", "/v1/inbox"]], + ["lease.queued", {}, ["/v1/lease-requests", "/v1/inbox"]], + ["lease.rejected", {}, ["/v1/lease-requests", "/v1/inbox"]], + ["lease.declined", {}, ["/v1/lease-requests", "/v1/inbox"]], + ["request.granted", {}, ["/v1/lease-requests", "/v1/inbox"]], + ["request.dispatched", {}, ["/v1/lease-requests", "/v1/inbox"]], + ["queue.changed", {}, ["/v1/lease-requests", "/v1/inbox"]], ["lease.renewed", { leaseId: "l_1" }, []], - ["device.ready", {}, ["/v1/workers"]], - ["device.quarantined", { leaseId: "l_1" }, ["/v1/workers", ...LEASES.slice(1)]], - ["worker.connected", {}, ["/v1/workers"]], - ["worker.drain-started", {}, ["/v1/workers"]], - ["capacity.changed", {}, ["/v1/workers"]], - ["component.installed", {}, ["/v1/workers"]], - ["daemon.update-started", {}, ["/v1/workers"]], + ["device.ready", {}, ["/v1/workers", "/v1/inbox"]], + ["device.quarantined", { leaseId: "l_1" }, ["/v1/workers", "/v1/inbox", ...LEASES.slice(1)]], + ["worker.connected", {}, ["/v1/workers", "/v1/inbox"]], + ["worker.drain-started", {}, ["/v1/workers", "/v1/inbox"]], + ["capacity.changed", {}, ["/v1/workers", "/v1/inbox"]], + ["component.installed", {}, ["/v1/workers", "/v1/inbox"]], + ["daemon.update-started", {}, ["/v1/workers", "/v1/inbox"]], ["disk.pressure-detected", {}, []], ["lease.something-new", {}, []], ]; diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 2e0edac9d..d907269c8 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -33,6 +33,7 @@ interface Times { } const TIMES: Readonly> = { + inbox: { freshMs: FRESH_MS, pollMs: 10_000 }, lease: { freshMs: FRESH_MS, pollMs: 10_000 }, "lease-requests": { freshMs: FRESH_MS, pollMs: 5_000 }, leases: { freshMs: FRESH_MS, pollMs: 10_000 }, diff --git a/ui/src/state/shell.ts b/ui/src/state/shell.ts index 2d8fb3ab9..42655b59e 100644 --- a/ui/src/state/shell.ts +++ b/ui/src/state/shell.ts @@ -1,21 +1,16 @@ import { useEffect, useState, useSyncExternalStore } from "react"; import type { ConnectionPhase } from "../data/live/connection"; -import { inboxItems, problemCount } from "../model/inbox"; +import { problemCount } from "../../../src/contract/inbox"; +import type { InboxList } from "../model/inbox"; import { formatDuration } from "../model/time"; import type { Toned } from "../model/tones"; -import type { WorkerList } from "../model/workers"; -import { useInboxReads } from "./inbox"; -import { useConnection, useRouteQuery, useNow } from "./live"; +import { useConnection, useNow, useRouteQuery } from "./live"; import { useApi, useSession } from "./services"; export function useInboxCount(): number | undefined { - const workers = useRouteQuery("/v1/workers").data?.workers; - const { leases, tokens, waiting } = useInboxReads(); - const now = useNow(); - if (workers === undefined) return undefined; - const requests = waiting.kind === "ready" ? waiting.data.requests : []; - return problemCount(inboxItems(workers, { leases, now: now.server, requests, tokens })); + const items = useRouteQuery("/v1/inbox").data?.items; + return items === undefined ? undefined : problemCount(items); } // why: a 401 at any time signs the operator out; a tab reloaded with a token since revoked finds out From 5daffb78c2dd339a6377a90d2bb9a09e8a99394e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:39:06 +0200 Subject: [PATCH 13/18] test: the console specs stub GET /v1/inbox (#915) --- e2e/console/inbox-stub.ts | 67 ++++++++++++++++ e2e/console/inbox.spec.ts | 148 +++++------------------------------ e2e/console/overview.spec.ts | 12 +-- e2e/console/shell.spec.ts | 75 +++++++----------- e2e/console/views.spec.ts | 26 +----- 5 files changed, 123 insertions(+), 205 deletions(-) create mode 100644 e2e/console/inbox-stub.ts diff --git a/e2e/console/inbox-stub.ts b/e2e/console/inbox-stub.ts new file mode 100644 index 000000000..3f9171017 --- /dev/null +++ b/e2e/console/inbox-stub.ts @@ -0,0 +1,67 @@ +import type { Page, Route } from "@playwright/test"; + +/** why: an Inbox item the daemon cannot make quickly (a request that waited minutes, a lease about to end), in the shape `GET /v1/inbox` sends. */ +export type StubbedItem = (now: number) => Record; + +export function waitingItem(id: string, waitedMs: number): StubbedItem { + return (now) => ({ + condition: "waiting", + group: "Needs a look", + key: `request/${id}/waiting`, + request: { + createdAt: now - waitedMs, + id, + queuePosition: 1, + requesterId: id, + spec: { platform: "ios" }, + stage: "queued", + }, + }); +} + +export function endingItem( + id: string, + leftMs: number, + names: { readonly holder?: string; readonly model?: string } = {}, +): StubbedItem { + return (now) => ({ + condition: "ending", + group: "Heads-up", + key: `lease/${id}/ending`, + lease: { + deviceId: "dev_stub", + grantedAt: now - 60_000, + holder: names.holder ?? "tok_stub", + id, + idChosenByRequester: false, + lastRenewedAt: now - 30_000, + model: names.model ?? "dev_stub", + ownerId: "tok_stub", + requesterId: "tok_stub", + ttlDeadline: now + leftMs, + ttlMs: 900_000, + }, + }); +} + +/** + * why: the items are added to what the daemon says, each counted from the daemon's own clock (its + * Date header); `items()` is read at every answer, so a test can empty the list. + */ +export async function addInboxItems( + page: Page, + items: () => readonly StubbedItem[], +): Promise { + await page.route( + (url) => url.pathname === "/v1/inbox", + async (route: Route) => { + const response = await route.fetch(); + const now = new Date(response.headers().date ?? "").getTime(); + const body = (await response.json()) as { items: unknown[] }; + await route.fulfill({ + json: { items: [...body.items, ...items().map((item) => item(now))] }, + response, + }); + }, + ); +} diff --git a/e2e/console/inbox.spec.ts b/e2e/console/inbox.spec.ts index 2a22ec66c..7fbbced80 100644 --- a/e2e/console/inbox.spec.ts +++ b/e2e/console/inbox.spec.ts @@ -1,4 +1,4 @@ -import type { Locator, Page, Route } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { waitFor } from "../helpers/wait.js"; import { @@ -12,6 +12,7 @@ import { startFleet, test, } from "./fixtures.js"; +import { addInboxItems, endingItem, waitingItem } from "./inbox-stub.js"; /** why: What `GET /v1/workers` says, as far as these tests read. */ interface WorkerView { @@ -208,33 +209,13 @@ test.describe("the Inbox view", () => { } }); - test("a waiting request stubbed with a createdAt 61 s before the daemon's now shows under Needs a look, and one at 30 s does not", async ({ + test("a waiting request item shows under Needs a look with its requester and the wait", async ({ page, }) => { const host = await startDaemon("worker", { driverScript: { ios: IOS } }); try { - // why: a request cannot wait a minute on a real daemon quickly, so the list is stubbed, each - // age counted from the daemon's own clock (its Date header). - await page.route( - (url) => url.pathname === "/v1/lease-requests", - async (route: Route) => { - const response = await route.fetch(); - const now = new Date(response.headers().date ?? "").getTime(); - const request = (id: string, waitedMs: number) => ({ - createdAt: now - waitedMs, - id, - platform: "ios", - queuePosition: 1, - requesterId: id, - spec: { platform: "ios" }, - stage: "queued", - }); - await route.fulfill({ - json: { requests: [request("long-waiter", 61_000), request("short-waiter", 30_000)] }, - response, - }); - }, - ); + // why: a request cannot wait a minute on a real daemon quickly, so the item is stubbed. + await addInboxItems(page, () => [waitingItem("long-waiter", 61_000)]); await openInbox(page, host); @@ -242,41 +223,18 @@ test.describe("the Inbox view", () => { await expect( needsALook.getByRole("link", { name: /long-waiter has waited 1 min/ }), ).toBeVisible(); - await expect(page.getByRole("link", { name: /short-waiter/ })).toHaveCount(0); } finally { await host.dispose(); } }); - test("a lease stubbed with 4 minutes left shows under Heads-up, and one with 10 minutes does not", async ({ + test("a lease item with 4 minutes left shows under Heads-up with its holder, device and time left", async ({ page, }) => { const host = await startDaemon("worker", { driverScript: { ios: IOS } }); try { - // why: a lease cannot be 4 minutes from its deadline on a real daemon quickly, so the list is - // stubbed, each deadline counted from the daemon's own clock (its Date header). - await page.route( - (url) => url.pathname === "/v1/leases", - async (route: Route) => { - const response = await route.fetch(); - const now = new Date(response.headers().date ?? "").getTime(); - const lease = (id: string, leftMs: number) => ({ - deviceId: "dev_stub", - grantedAt: now - 60_000, - id, - idChosenByRequester: false, - lastRenewedAt: now - 30_000, - ownerId: "tok_stub", - requesterId: "tok_stub", - ttlDeadline: now + leftMs, - ttlMs: 900_000, - }); - await route.fulfill({ - json: { leases: [lease("lease-soon", 4 * 60_000), lease("lease-later", 10 * 60_000)] }, - response, - }); - }, - ); + // why: a lease cannot be 4 minutes from its deadline on a real daemon quickly, so the item is stubbed. + await addInboxItems(page, () => [endingItem("lease-soon", 4 * 60_000)]); await openInbox(page, host); @@ -293,32 +251,8 @@ test.describe("the Inbox view", () => { test("an Inbox item whose key holds a . reloads at its address", async ({ page }) => { const host = await startDaemon("worker", { driverScript: { ios: IOS } }); try { - // why: a gateway's lease id holds a `.`, which a single host never makes, so the list is stubbed. - await page.route( - (url) => url.pathname === "/v1/leases", - async (route: Route) => { - const response = await route.fetch(); - const now = new Date(response.headers().date ?? "").getTime(); - await route.fulfill({ - json: { - leases: [ - { - deviceId: "dev_stub", - grantedAt: now - 60_000, - id: "wrk_a.lse_1", - idChosenByRequester: false, - lastRenewedAt: now - 30_000, - ownerId: "tok_stub", - requesterId: "tok_stub", - ttlDeadline: now + 4 * 60_000, - ttlMs: 900_000, - }, - ], - }, - response, - }); - }, - ); + // why: a gateway's lease id holds a `.`, which a single host never makes, so the item is stubbed. + await addInboxItems(page, () => [endingItem("wrk_a.lse_1", 4 * 60_000)]); await openInbox(page, host); await page.getByRole("region", { name: "Heads-up" }).getByRole("link").click(); @@ -368,22 +302,22 @@ test.describe("the Inbox view", () => { } }); - test("with /v1/lease-requests stubbed to fail, the Inbox says Could not check: waiting requests and not Nothing needs you", async ({ + test("with /v1/inbox stubbed to fail, the Inbox shows the daemon's refusal and not Nothing needs you", async ({ page, }) => { const host = await startDaemon("worker", { driverScript: { ios: IOS } }); try { await page.route( - (url) => url.pathname === "/v1/lease-requests", + (url) => url.pathname === "/v1/inbox", (route) => route.fulfill({ - json: { error: { code: "INTERNAL", message: "The queue is down." } }, + json: { error: { code: "INTERNAL", message: "The inbox is down." } }, status: 500, }), ); await openInbox(page, host); - await expect(page.getByText("Could not check: waiting requests")).toBeVisible(); + await expect(page.getByText("The inbox is down.")).toBeVisible(); await expect(page.getByText("Nothing needs you.")).toHaveCount(0); } finally { await host.dispose(); @@ -612,55 +546,13 @@ test.describe("the Inbox view", () => { (views) => views.filter((view) => view.connection === "incompatible").length === 2, ); // why: a request that has waited two minutes is a problem item and counts; a lease about to end - // is a heads-up and does not. Neither can be had quickly from a real daemon, so both are stubbed, - // the waiting list by `waiters` so that it can empty. + // is a heads-up and does not. Neither can be had quickly from a real daemon, so both are stubbed + // into the daemon's items, the waiting one by `waiters` so that it can empty. let waiters = ["long-waiter"]; - await page.route( - (url) => url.pathname === "/v1/lease-requests", - async (route: Route) => { - const response = await route.fetch(); - const now = new Date(response.headers().date ?? "").getTime(); - await route.fulfill({ - json: { - requests: waiters.map((id) => ({ - createdAt: now - 125_000, - id, - platform: "ios", - queuePosition: 1, - requesterId: id, - spec: { platform: "ios" }, - stage: "queued", - })), - }, - response, - }); - }, - ); - await page.route( - (url) => url.pathname === "/v1/leases", - async (route: Route) => { - const response = await route.fetch(); - const now = new Date(response.headers().date ?? "").getTime(); - await route.fulfill({ - json: { - leases: [ - { - deviceId: "dev_stub", - grantedAt: now - 60_000, - id: "lease-soon", - idChosenByRequester: false, - lastRenewedAt: now - 30_000, - ownerId: "tok_stub", - requesterId: "tok_stub", - ttlDeadline: now + 60_000, - ttlMs: 900_000, - }, - ], - }, - response, - }); - }, - ); + await addInboxItems(page, () => [ + ...waiters.map((id) => waitingItem(id, 125_000)), + endingItem("lease-soon", 60_000), + ]); await page.goto(gateway.url); await signIn(page, gateway.tokens.operator); diff --git a/e2e/console/overview.spec.ts b/e2e/console/overview.spec.ts index 4e6fe7be8..a91938f81 100644 --- a/e2e/console/overview.spec.ts +++ b/e2e/console/overview.spec.ts @@ -361,22 +361,22 @@ test.describe("the Overview's Inbox lines", () => { } }); - test("with /v1/lease-requests stubbed to fail, Overview says Could not check: waiting requests and not Nothing needs you", async ({ + test("with /v1/inbox stubbed to fail, Overview shows the daemon's refusal and not Nothing needs you", async ({ page, }) => { const host = await startDaemon("worker", { driverScript: { ios: IOS } }); try { await page.route( - (url) => url.pathname === "/v1/lease-requests", + (url) => url.pathname === "/v1/inbox", (route) => route.fulfill({ - json: { error: { code: "INTERNAL", message: "The queue is down." } }, + json: { error: { code: "INTERNAL", message: "The inbox is down." } }, status: 500, }), ); await openOverview(page, host); - await expect(page.getByText("Could not check: waiting requests")).toBeVisible(); + await expect(page.getByText("The inbox is down.")).toBeVisible(); await expect(page.getByText("Nothing needs you")).toHaveCount(0); } finally { await host.dispose(); @@ -385,7 +385,7 @@ test.describe("the Overview's Inbox lines", () => { }); test.describe("the Overview when the workers cannot be read", () => { - test("with /v1/workers stubbed to fail, Overview says Could not check: workers and shows no capacity", async ({ + test("with /v1/workers stubbed to fail, Overview shows the daemon's refusal and no capacity", async ({ page, }) => { const host = await startDaemon("worker", { driverScript: { ios: IOS } }); @@ -402,7 +402,7 @@ test.describe("the Overview when the workers cannot be read", () => { ); await page.reload(); - await expect(page.getByText("Could not check: workers")).toBeVisible(); + await expect(page.getByText("The fleet is down.")).toBeVisible(); await expect(page.getByRole("region", { name: "Capacity · all workers" })).toHaveCount(0); await expect(page.getByText("Nothing needs you")).toHaveCount(0); } finally { diff --git a/e2e/console/shell.spec.ts b/e2e/console/shell.spec.ts index 9c11e08e2..2c119c632 100644 --- a/e2e/console/shell.spec.ts +++ b/e2e/console/shell.spec.ts @@ -1,5 +1,5 @@ import { AxeBuilder } from "@axe-core/playwright"; -import type { Page, Route } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { expect, @@ -13,6 +13,7 @@ import { test, withActionSurfaces, } from "./fixtures.js"; +import { addInboxItems, endingItem, waitingItem } from "./inbox-stub.js"; async function hasHorizontalScroll(page: Page): Promise { return page.evaluate( @@ -45,6 +46,27 @@ test.describe("the shell", () => { expect(await hasHorizontalScroll(page)).toBe(false); }); + test("the sidebar's Inbox count reads only /v1/inbox on every page, not the fleet lists", async ({ + daemon, + page, + }) => { + const asked = new Map(); + page.on("request", (request) => { + const { pathname } = new URL(request.url()); + if (pathname.startsWith("/v1/")) asked.set(pathname, (asked.get(pathname) ?? 0) + 1); + }); + await page.goto("/usage"); + await signIn(page, daemon.tokens.operator); + await expect(page.getByRole("heading", { level: 1, name: "Usage" })).toBeVisible(); + await expect.poll(() => asked.get("/v1/inbox")).toBe(1); + + // why: the token check at start reads /v1/workers once outside the query layer, so one is allowed. + expect(asked.get("/v1/workers") ?? 0).toBeLessThanOrEqual(1); + for (const path of ["/v1/leases", "/v1/tokens", "/v1/lease-requests"]) { + expect(asked.get(path) ?? 0, path).toBe(0); + } + }); + test("every control in the shell is reachable and usable with the keyboard alone", async ({ browserName, daemon, @@ -266,53 +288,10 @@ test.describe("the shell", () => { // why: a request that has waited two minutes and a lease about to end fill the Inbox's groups // and the Overview's panel; neither can be had quickly from a real daemon, so both are stubbed. - await page.route( - (url) => url.pathname === "/v1/lease-requests", - async (route: Route) => { - const response = await route.fetch(); - const now = new Date(response.headers().date ?? "").getTime(); - await route.fulfill({ - json: { - requests: [ - { - createdAt: now - 125_000, - id: "req_busy", - queuePosition: 1, - requesterId: "busy-agent", - spec: { platform: "ios" }, - stage: "queued", - }, - ], - }, - response, - }); - }, - ); - await page.route( - (url) => url.pathname === "/v1/leases", - async (route: Route) => { - const response = await route.fetch(); - const now = new Date(response.headers().date ?? "").getTime(); - await route.fulfill({ - json: { - leases: [ - { - deviceId: "dev_busy", - grantedAt: now - 60_000, - id: "lse_busy", - idChosenByRequester: false, - lastRenewedAt: now - 30_000, - ownerId: "tok_busy", - requesterId: "tok_busy", - ttlDeadline: now + 60_000, - ttlMs: 900_000, - }, - ], - }, - response, - }); - }, - ); + await addInboxItems(page, () => [ + waitingItem("req_busy", 125_000), + endingItem("lse_busy", 60_000, { holder: "tok_busy", model: "dev_busy" }), + ]); await page.goto("/inbox"); await expect(page.getByRole("region", { name: "Needs a look" })).toBeVisible(); await expect(page.getByRole("region", { name: "Heads-up" })).toBeVisible(); diff --git a/e2e/console/views.spec.ts b/e2e/console/views.spec.ts index 41a556642..ee9c2d173 100644 --- a/e2e/console/views.spec.ts +++ b/e2e/console/views.spec.ts @@ -1,5 +1,5 @@ import { AxeBuilder } from "@axe-core/playwright"; -import type { Page, Route } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { expect, @@ -14,6 +14,7 @@ import { test, withActionSurfaces, } from "./fixtures.js"; +import { addInboxItems, waitingItem } from "./inbox-stub.js"; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; const LEASE = ["lease", "--platform", "ios", "--device", "iPhone 16", "--os", "18.4"] as const; @@ -285,28 +286,7 @@ test.describe("every view", () => { } // why: a healthy host has nothing in its Inbox, so a request that has waited two minutes is // stubbed to give the Inbox a row and its drawer something to open. - await page.route( - (url) => url.pathname === "/v1/lease-requests", - async (route: Route) => { - const response = await route.fetch(); - const now = new Date(response.headers().date ?? "").getTime(); - await route.fulfill({ - json: { - requests: [ - { - createdAt: now - 125_000, - id: "req_busy", - queuePosition: 1, - requesterId: "busy-agent", - spec: { platform: "ios" }, - stage: "queued", - }, - ], - }, - response, - }); - }, - ); + await addInboxItems(page, () => [waitingItem("req_busy", 125_000)]); for (const path of [ "/inbox", "/", From 82144e43c246db69b6496c58a2c1ad842945f894 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:40:57 +0200 Subject: [PATCH 14/18] docs: the Inbox is the daemon's, GET /v1/inbox (#915) --- src/contract/AGENTS.md | 31 ++++++++++++++++--------------- src/daemon/AGENTS.md | 2 +- src/gateway/AGENTS.md | 2 +- ui/AGENTS.md | 6 ++++-- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/contract/AGENTS.md b/src/contract/AGENTS.md index 675d052ca..a482aba9d 100644 --- a/src/contract/AGENTS.md +++ b/src/contract/AGENTS.md @@ -9,20 +9,21 @@ Imported by: daemon, gateway, simlock-client, every frontend, core, leasing, his ## Files -| File | Role | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `operations.ts` | Every operation: name, role, effect, input, output, `authorize`; `OPERATIONS` | -| `schemas.ts` | Wire shapes, re-declared by hand from core; `fit*` bounders | -| `roles.ts` | `Role`, `AuthorizeContext`, `ownsLease` | -| `errors.ts` | `ErrorDetailsMap`, `ERROR_TABLE`, `SimlockError`, `fromWireError` | -| `pushes.ts` | `PUSH_SCHEMAS`: every push kind | -| `protocol.ts` | `PROTOCOL_VERSION_RANGE`, negotiation, `hello` schemas | -| `worker-view.ts` | `workerViewFields`, view refresh interval and events | -| `os-range.ts` | OS constraint grammar: parse, `satisfies`, `compareVersions` | -| `version-order.ts` | `isNewerVersion(candidate, running)`: the one "newer" rule (semver order) every update surface shares; the console imports it by this path, not the index (the index carries zod) | -| `duration.ts` | `parseDurationMs`, for the CLI and HTTP | -| `relay.ts` | Bounded relay ids, channel ids, headers | -| `console-url.ts` | The console address `status.get` reports | +| File | Role | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `operations.ts` | Every operation: name, role, effect, input, output, `authorize`; `OPERATIONS` | +| `schemas.ts` | Wire shapes, re-declared by hand from core; `fit*` bounders | +| `roles.ts` | `Role`, `AuthorizeContext`, `ownsLease` | +| `errors.ts` | `ErrorDetailsMap`, `ERROR_TABLE`, `SimlockError`, `fromWireError` | +| `pushes.ts` | `PUSH_SCHEMAS`: every push kind | +| `protocol.ts` | `PROTOCOL_VERSION_RANGE`, negotiation, `hello` schemas | +| `worker-view.ts` | `workerViewFields`, view refresh interval and events | +| `inbox.ts` | The Inbox rules: `inboxItems` (what needs the operator, with each item's group, key and subject), `problemCount`, and the helpers they call (`holderOf`, `workerOfLease`, `isExpiring`, ...); no runtime import, so the console imports it by this path | +| `os-range.ts` | OS constraint grammar: parse, `satisfies`, `compareVersions` | +| `version-order.ts` | `isNewerVersion(candidate, running)`: the one "newer" rule (semver order) every update surface shares; the console imports it by this path, not the index (the index carries zod) | +| `duration.ts` | `parseDurationMs`, for the CLI and HTTP | +| `relay.ts` | Bounded relay ids, channel ids, headers | +| `console-url.ts` | The console address `status.get` reports | ## Operations @@ -34,7 +35,7 @@ every operation but `daemon.stop`, which `DaemonServer` answers. - `agent`: `catalog.get`, `status.get`, `component.list`, `lease.request`, `lease.cancel`, `lease.renew`, `lease.release`, `lease.list`, `lease.get`, `driver.passthrough`, `device.exec`, `doctor.run` without `fix` or `purgeOrphans`. -- `admin`: every other operation (`list.get`, `config.get`, `daemon.stop`, `events.*`, +- `admin`: every other operation (`list.get`, `config.get`, `inbox.get`, `daemon.stop`, `events.*`, `token.*`, `worker.*`, `component.install`, `device.add`, `relay.request`, ...). - `role` and `effect` may be functions of the validated input: `doctor.run` (role, effect), `cleanup.run` (`dryRun` is a `read`). diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index 2bb2d7f77..22034cb85 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -129,7 +129,7 @@ Security model: cooperative identity, not a hostile-process boundary. - Events: ring of `eventBuffer.capacity` (1000) in memory; `history.db` via the [history](../history/AGENTS.md), kept `eventLog.retention` (7 days), event rows capped at `eventLog.maxBytes` (256 MiB); usage rows are kept `metrics.retention` (90 days). `onSocketClaimed` starts it in both modes with the role and `metrics.retention`; `dispose` stops it last. A file that fails costs only history. A start the history refuses (a newer stored projection version, a - failed migration) stops the daemon with reason `history-refused` and exit 1. `status.get` carries its `history`, also while starting. `usage.get` (both dispatchers, `usageAnswer` in `dispatch.ts`) reads the same file's usage rows through `core/usage` on a read thread; `main.ts` passes `historyPath` in both modes, and `dispose` stops the thread. `history.status().opened` false → `HISTORY_UNAVAILABLE`; a window ending before the oldest row → `HISTORY_NOT_KEPT`. + failed migration) stops the daemon with reason `history-refused` and exit 1. `status.get` carries its `history`, also while starting. `inbox.get` (worker mode) runs `inboxItems` over its own `worker.list` view, `lease.list` and its queue. `usage.get` (both dispatchers, `usageAnswer` in `dispatch.ts`) reads the same file's usage rows through `core/usage` on a read thread; `main.ts` passes `historyPath` in both modes, and `dispose` stops the thread. `history.status().opened` false → `HISTORY_UNAVAILABLE`; a window ending before the oldest row → `HISTORY_NOT_KEPT`. - SIGTERM to a running daemon runs `stop("signal")` and exits 0; one that arrives before the start finishes exits 0 at once (one handler in `main.ts`, the entry). A start that finds a daemon answering logs `Daemon already running` at `info` to the role's `daemon.log` and exits 0. A start that throws is logged by a fallback logger to the role's `daemon.log`; exit 1. An entry argument other than none or `--gateway` is logged to `/daemon.log` as "Daemon failed to start"; exit 1. ## Rules diff --git a/src/gateway/AGENTS.md b/src/gateway/AGENTS.md index 1dc572d14..6ba7d1a9d 100644 --- a/src/gateway/AGENTS.md +++ b/src/gateway/AGENTS.md @@ -101,7 +101,7 @@ missing from 2 snapshots in a row → dropped; kept across a disconnect, forgott **Reads.** `status.get`, `catalog.get`, `list.get`, `lease.list` (admin: all; else own) and `lease.get` (one lease, as `lease.list` would hold it for the caller) answer from views; a gateway lease record -also carries `workerLeaseId`, the worker's own id (not on `status.get`). `worker.install-component` fans out. `nuke.run`, `cleanup.run`, `doctor.run`, `driver.passthrough`, `component.*`, `device.add`, `device.remove` → `UNSUPPORTED_IN_GATEWAY_MODE`. `usage.get` reads this gateway's `history.db` rows through `core/usage`: fleet totals, one entry per registry or named worker; `dispose` stops its read thread. +also carries `workerLeaseId`, the worker's own id (not on `status.get`). `inbox.get` runs `inboxItems` (contract) over the worker views, `#fleetLeases()`, `#fleetWaiting()` and the token list (an unavailable store leaves holders named by id). `worker.install-component` fans out. `nuke.run`, `cleanup.run`, `doctor.run`, `driver.passthrough`, `component.*`, `device.add`, `device.remove` → `UNSUPPORTED_IN_GATEWAY_MODE`. `usage.get` reads this gateway's `history.db` rows through `core/usage`: fleet totals, one entry per registry or named worker; `dispose` stops its read thread. **Uplink drop.** View → `disconnected`, kept; `worker.disconnected`. In-flight calls → `WORKER_UNREACHABLE`, an in-flight lease request → `worker-failed`. `token.revoke` closes its uplinks. diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 6090d5e98..eff854d72 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -2,7 +2,7 @@ Does: the operator's web console — a React page the daemon serves from `dist/ui`. Entry: `src/main.tsx` → `App` (`src/app/app.tsx`). -Uses: the daemon's `/v1` HTTP API and event stream; contract types from `src/contract/` (type-only), and `version-order.ts`'s `isNewerVersion` as a value, imported by its own path (the index would pull the schemas, and zod's `eval`, into the bundle); this one `past-index` exemption in `.dependency-cruiser.mjs` is maintainer-authorized (#825 Decisions). +Uses: the daemon's `/v1` HTTP API and event stream; contract types from `src/contract/` (type-only), and two files as values, each imported by its own path (the index would pull the schemas, and zod's `eval`, into the bundle): `version-order.ts`'s `isNewerVersion` and `inbox.ts`'s helpers (`holderOf`, `isExpiring`, `problemCount`, ...), which the daemon's Inbox rules share. Both `past-index` exemptions in `.dependency-cruiser.mjs` are maintainer-authorized (#825 Decisions, ADR 0044 §3). Surface: [console.mdx](../website/src/content/docs/docs/worker/console.mdx). Look and voice: [DESIGN.md](../docs/internal/DESIGN.md). ## Layers @@ -57,9 +57,11 @@ and `useDaemonFacts` (`state/live.tsx`) give them to state and to the sidebar's ## Reads +The Inbox is the daemon's: `GET /v1/inbox` (`inbox.get`, rules in `src/contract/inbox.ts`) answers the items, and `['inbox']` is the only key the sidebar's count reads, on every page. The Inbox page and the Overview's Needs you read it and `['workers']` (for names, links and lease counts); a read that fails shows the page's refusal. `model/inbox.ts` only draws rows, tones, filters and the drawer from the items, counting a wait or the time left from the item's own timestamp against the daemon's clock. Events that mark workers, leases or requests stale mark `['inbox']` too. + A page reads a route only while it is on screen (`useRouteQuery`, `state/live.tsx`). `state/queries.ts` holds the TanStack `QueryClient` (the dependency rules keep it out of `data/` and `model/`) and the keys and times of ADR 0044: -`['workers']` every 15 s, `['leases']` and `['lease', id]` 10 s, `['lease-requests']` 5 s, +`['workers']` every 15 s, `['leases']`, `['lease', id]` and `['inbox']` 10 s, `['lease-requests']` 5 s, `['stats', range]` 60 s, `['tokens']` never; a read stays fresh 2 s (stats 30 s, tokens 5 min); data no page uses is dropped after 60 s. A key polls only while a page uses it, the tab is shown and the connection is up; a hidden tab or a lost connection reads nothing, and coming back reads each key From af136424c1151489f3403fc5db474c0243f5f2b3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:44:51 +0200 Subject: [PATCH 15/18] test: the console specs stub GET /v1/inbox (#915) --- e2e/console/actions.spec.ts | 17 ++++------------- e2e/console/inbox-stub.ts | 16 ++++++++++++++++ e2e/console/shell.spec.ts | 5 +++-- src/contract/inbox.ts | 4 ++-- src/contract/schemas.ts | 8 ++++---- ui/src/screens/overview/index.tsx | 10 +++------- 6 files changed, 32 insertions(+), 28 deletions(-) diff --git a/e2e/console/actions.spec.ts b/e2e/console/actions.spec.ts index 9cfa7acc6..b62cd1f58 100644 --- a/e2e/console/actions.spec.ts +++ b/e2e/console/actions.spec.ts @@ -10,6 +10,7 @@ import { startFleet, test, } from "./fixtures.js"; +import { addInboxItems, drainedWorkerItem } from "./inbox-stub.js"; const ONE_DEVICE = { limits: { ios: { maxDevices: 1, maxRunning: 1 }, maxRunning: 1 } }; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; @@ -668,19 +669,9 @@ test.describe("the console acts", () => { await expect(page.getByRole("menuitem")).toHaveText(["Release lease"]); await page.keyboard.press("Escape"); - // why: a single host has no drained worker, so the list says one is, to give the Inbox a row - // that offers Undrain on a gateway. - await page.route( - (url) => url.pathname === "/v1/workers", - async (route) => { - const response = await route.fetch(); - const body = (await response.json()) as { workers: Record[] }; - await route.fulfill({ - json: { ...body, workers: body.workers.map((view) => ({ ...view, drained: true })) }, - response, - }); - }, - ); + // why: a single host has no drained worker, so the Inbox is given a drained worker's item, to + // give it a row that offers Undrain on a gateway. + await addInboxItems(page, () => [drainedWorkerItem("wrk_solo")]); await page.goto(new URL("/inbox", daemon.url).toString()); await expect(page.getByRole("heading", { level: 1, name: "Inbox" })).toBeVisible(); await expect(page.locator("main .inbox-list li")).toHaveCount(1); diff --git a/e2e/console/inbox-stub.ts b/e2e/console/inbox-stub.ts index 3f9171017..19f94b299 100644 --- a/e2e/console/inbox-stub.ts +++ b/e2e/console/inbox-stub.ts @@ -19,6 +19,22 @@ export function waitingItem(id: string, waitedMs: number): StubbedItem { }); } +export function drainedWorkerItem(id: string): StubbedItem { + return (now) => ({ + condition: "drained", + group: "Needs a look", + key: `${id}/drained`, + worker: { + connection: "connected", + drained: true, + heldLeases: 0, + id, + lastSeenAt: now, + leasedDevices: 0, + }, + }); +} + export function endingItem( id: string, leftMs: number, diff --git a/e2e/console/shell.spec.ts b/e2e/console/shell.spec.ts index 2c119c632..ea2e31c2f 100644 --- a/e2e/console/shell.spec.ts +++ b/e2e/console/shell.spec.ts @@ -60,8 +60,9 @@ test.describe("the shell", () => { await expect(page.getByRole("heading", { level: 1, name: "Usage" })).toBeVisible(); await expect.poll(() => asked.get("/v1/inbox")).toBe(1); - // why: the token check at start reads /v1/workers once outside the query layer, so one is allowed. - expect(asked.get("/v1/workers") ?? 0).toBeLessThanOrEqual(1); + // why: the sign-in check and the token check at start each read /v1/workers outside the query + // layer, so two are allowed. + expect(asked.get("/v1/workers") ?? 0).toBeLessThanOrEqual(2); for (const path of ["/v1/leases", "/v1/tokens", "/v1/lease-requests"]) { expect(asked.get(path) ?? 0, path).toBe(0); } diff --git a/src/contract/inbox.ts b/src/contract/inbox.ts index e2c235ecb..16f8f3f09 100644 --- a/src/contract/inbox.ts +++ b/src/contract/inbox.ts @@ -11,7 +11,7 @@ import type { workerViewSchema, } from "./schemas.js"; -export type InboxItem = z.infer; +type InboxItem = z.infer; type InboxGroup = InboxItem["group"]; type WorkerItem = Extract; type WorkerView = z.infer; @@ -20,7 +20,7 @@ type LeaseRecord = z.infer; type TokenRecord = z.infer; type WaitingRequest = z.infer; -export interface InboxContext { +interface InboxContext { readonly now: number; readonly requests?: readonly WaitingRequest[] | undefined; readonly leases?: readonly LeaseRecord[] | undefined; diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 4ea3f3310..d8ac9f89f 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -1387,17 +1387,17 @@ export const statusLeaseSchema = leaseRecordSchema.extend({ workerLeaseId: z.string().optional(), }); -export const inboxGroupSchema = z.enum(["Broken", "Needs a look", "Heads-up"]); +const inboxGroupSchema = z.enum(["Broken", "Needs a look", "Heads-up"]); // why: an Inbox item names the worker it is about by the view's fields that the page does not read from the worker list, plus two counts the view's lists would otherwise have to be sent for. -export const inboxWorkerSchema = workerViewSchema +const inboxWorkerSchema = workerViewSchema .omit({ catalog: true, devices: true, downloads: true, leases: true }) .extend({ heldLeases: z.number().int().nonnegative(), leasedDevices: z.number().int().nonnegative(), }); -export const inboxDeviceSchema = statusDeviceSchema.and( +const inboxDeviceSchema = statusDeviceSchema.and( z.object({ stateEnteredAt: z.number().optional(), workerId: z.string(), @@ -1405,7 +1405,7 @@ export const inboxDeviceSchema = statusDeviceSchema.and( }), ); -export const inboxLeaseSchema = statusLeaseSchema.extend({ +const inboxLeaseSchema = statusLeaseSchema.extend({ holder: z.string(), model: z.string(), }); diff --git a/ui/src/screens/overview/index.tsx b/ui/src/screens/overview/index.tsx index 71c37af0b..9e5d2c390 100644 --- a/ui/src/screens/overview/index.tsx +++ b/ui/src/screens/overview/index.tsx @@ -30,13 +30,9 @@ export function OverviewScreen() { const view = useOverview(range); return ( - {view.kind === "refused" ? ( -

Could not check: workers

- ) : ( - - {(overview) => } - - )} + + {(overview) => } +
); } From 3c03e45c554899bd20ba6d653acef96063672ccb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 14:53:20 +0200 Subject: [PATCH 16/18] fix(daemon): the socket answers inbox.get (#915) --- src/daemon/server.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 263f2fec7..14659ce89 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -1113,6 +1113,12 @@ export class DaemonServer { frame.payload ?? {}, this.#session(connection), ); + case "inbox.get": + return this.#dispatcher.dispatch( + "inbox.get", + frame.payload ?? {}, + this.#session(connection), + ); case "events.subscribe": return this.#dispatcher.dispatch( "events.subscribe", From 55c57893497cba2f0782c2ecbea50aef524d61c0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:18:32 +0200 Subject: [PATCH 17/18] test: the audit proves the Inbox rules' ties, holders, token store and Inbox page reads (#915) --- e2e/console/shell.spec.ts | 10 +++++++++- src/contract/inbox.test.ts | 25 +++++++++++++++++++++++-- src/daemon/dispatcher.test.ts | 33 ++++++++++++++++++++++----------- ui/src/model/actions.test.ts | 4 ++++ ui/src/model/inbox.test.ts | 15 +++++++++++++++ 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/e2e/console/shell.spec.ts b/e2e/console/shell.spec.ts index ea2e31c2f..227735a9f 100644 --- a/e2e/console/shell.spec.ts +++ b/e2e/console/shell.spec.ts @@ -46,7 +46,7 @@ test.describe("the shell", () => { expect(await hasHorizontalScroll(page)).toBe(false); }); - test("the sidebar's Inbox count reads only /v1/inbox on every page, not the fleet lists", async ({ + test("the sidebar's Inbox count reads only /v1/inbox on every page, and the Inbox page reads no leases, tokens or requests", async ({ daemon, page, }) => { @@ -66,6 +66,14 @@ test.describe("the shell", () => { for (const path of ["/v1/leases", "/v1/tokens", "/v1/lease-requests"]) { expect(asked.get(path) ?? 0, path).toBe(0); } + + // why: the Inbox page reads the items and the worker list, and the page is drawn once both are in, + // so a read of leases, tokens or requests would have been asked by then. + await page.getByRole("link", { name: /^Inbox/ }).click(); + await expect(page.getByText("Nothing needs you.")).toBeVisible(); + for (const path of ["/v1/leases", "/v1/tokens", "/v1/lease-requests"]) { + expect(asked.get(path) ?? 0, path).toBe(0); + } }); test("every control in the shell is reachable and usable with the keyboard alone", async ({ diff --git a/src/contract/inbox.test.ts b/src/contract/inbox.test.ts index 1b0bb7b88..a2f327044 100644 --- a/src/contract/inbox.test.ts +++ b/src/contract/inbox.test.ts @@ -93,7 +93,7 @@ describe("the inbox rules", () => { to: "0.4.0", } as const; const workers: WorkerView[] = [ - worker("wrk_ok", { capacity: ramBudget(false) }), + worker("wrk_ok", { capacity: ramBudget(false), lastUpdateFailure: null }), worker("wrk_gone", { connection: "disconnected", drained: true, leases: [lease("l1", 1e6)] }), worker("wrk_old", { connection: "incompatible" }), worker("wrk_upd", { @@ -114,19 +114,28 @@ describe("the inbox rules", () => { state: "absent", }), ], + drained: true, label: "mac-08", }), + worker("wrk_lost", { + connection: "disconnected", + devices: [ + device("dev_lost", { state: "reclaiming", stalled: true, transitionAgeMs: 5_000 }), + ], + }), ]; const requests = [ request("req_old", 60_001), request("req_edge", 60_000), request("req_new", 59_000), request("req_longest", 90_000), + request("req_tie_b", 70_000), + request("req_tie_a", 70_000), ]; const leases = [ lease("lse_far", 360_000), lease("lse_edge", 300_000), - lease("lse_near", 240_000, { workerId: "wrk_upd" }), + lease("lse_near", 240_000, { requesterId: "gw:g1:tok_1", workerId: "wrk_upd" }), lease("lse_now", 10_000, { deviceId: "dev_gone", requesterId: "gw:g1:agent-9" }), ]; @@ -138,10 +147,15 @@ describe("the inbox rules", () => { ["wrk_old/incompatible", "Broken", "incompatible"], ["wrk_upd/update-failed", "Needs a look", "update failed"], ["wrk_ram/ram", "Needs a look", "over RAM budget"], + ["wrk_dev/drained", "Needs a look", "drained"], ["wrk_dev/dev_q/quarantined", "Broken", "quarantined"], ["wrk_dev/dev_stuck/stalled", "Needs a look", "stalled"], ["wrk_dev/dev_phone/absent", "Needs a look", "absent"], + ["wrk_lost/disconnected", "Broken", "disconnected"], + ["wrk_lost/dev_lost/stalled", "Needs a look", "stalled"], ["request/req_longest/waiting", "Needs a look", "waiting"], + ["request/req_tie_a/waiting", "Needs a look", "waiting"], + ["request/req_tie_b/waiting", "Needs a look", "waiting"], ["request/req_old/waiting", "Needs a look", "waiting"], ["lease/lse_now/ending", "Heads-up", "ending"], ["lease/lse_near/ending", "Heads-up", "ending"], @@ -182,5 +196,12 @@ describe("the inbox rules", () => { expect(byKey("lease/lse_now/ending")).toMatchObject({ lease: { holder: "agent-9", model: "dev_gone" }, }); + expect(byKey("wrk_lost/dev_lost/stalled")).not.toHaveProperty("device.stateEnteredAt"); + expect( + inboxItems([worker("wrk_solo", { devices: [device("dev_leased")] })], { + leases: [lease("lse_solo", 10_000)], + now: NOW, + }).map((item) => item.condition === "ending" && item.lease.model), + ).toEqual(["iPhone 16"]); }); }); diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 74f57304d..b32226ead 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -2320,25 +2320,36 @@ describe("Dispatcher: list.get requests and status.get waiting", () => { const requests = (dispatcher: Dispatcher) => dispatcher.dispatch("list.get", { kind: "requests" }, session({ role: "admin" })); - it("inbox.get on a worker answers a lease about to end and a request that has waited over 60 s", async () => { - const { clock, dispatcher, engine } = await buildDispatcher(); - await request(dispatcher, "holder-1", { ttlMs: 250_000 }); - await request(dispatcher, "holder-2"); - void request(dispatcher, "agent-a"); - await expect.poll(() => engine.queueDepth).toBe(1); - clock.advance(60_001); + it("inbox.get on a worker answers a lease about to end and a request that has waited over 60 s, naming a holder by its token's label, or by its id when the daemon has no token store", async () => { + const holders = async (withoutTokens: boolean) => { + const { clock, dispatcher, engine, tokens } = await buildDispatcher({ withoutTokens }); + const { record } = await tokens.create("agent", "ci-bot"); + await request(dispatcher, record.id, { ttlMs: 250_000 }); + await request(dispatcher, "holder-2"); + void request(dispatcher, "agent-a"); + await expect.poll(() => engine.queueDepth).toBe(1); + clock.advance(60_001); + const { items } = await dispatcher.dispatch("inbox.get", {}, session({ role: "admin" })); + return { id: record.id, items }; + }; - const { items } = await dispatcher.dispatch("inbox.get", {}, session({ role: "admin" })); + const labelled = await holders(false); + const bare = await holders(true); - expect(items.map((item) => [item.key.replace(/\/[^/]+\//, "/id/"), item.condition])).toEqual([ + expect( + labelled.items.map((item) => [item.key.replace(/\/[^/]+\//, "/id/"), item.condition]), + ).toEqual([ ["request/id/waiting", "waiting"], ["lease/id/ending", "ending"], ]); - expect(items).toContainEqual( + expect(labelled.items).toContainEqual( expect.objectContaining({ - lease: expect.objectContaining({ holder: "holder-1", model: "iPhone 17 Pro" }), + lease: expect.objectContaining({ holder: "ci-bot", model: "iPhone 17 Pro" }), }), ); + expect(bare.items).toContainEqual( + expect.objectContaining({ lease: expect.objectContaining({ holder: bare.id }) }), + ); }); it("list.get requests on a worker returns every open request with its queue position", async () => { diff --git a/ui/src/model/actions.test.ts b/ui/src/model/actions.test.ts index adf791c11..8dce68774 100644 --- a/ui/src/model/actions.test.ts +++ b/ui/src/model/actions.test.ts @@ -194,6 +194,10 @@ function checksInbox(): void { expect(heldLease.menu[0]?.confirm?.body).toBe( "iPhone 16 on mac-mini-1 goes back to the pool now, and agent-1's next command on it fails.", ); + expect(heldLease.menu[0]?.success.view).toEqual({ + label: "View device", + to: "/fleet/wrk_1/dev_1", + }); const drainedItem = items.find((item) => item.key === "wrk_drained/drained"); if (drainedItem === undefined) throw new Error("no drained item"); expect(inboxActions(drainedItem, { ...context, mode: undefined })).toEqual({ menu: [] }); diff --git a/ui/src/model/inbox.test.ts b/ui/src/model/inbox.test.ts index 09e277fcf..1e2e78ec6 100644 --- a/ui/src/model/inbox.test.ts +++ b/ui/src/model/inbox.test.ts @@ -550,5 +550,20 @@ describe("the Inbox page", () => { open: "Open worker", to: "/fleet/wrk%2F1", }); + const ofDevice = pageOf( + [ + worker({ + devices: [device("dev_1", { state: "quarantined" })], + id: "wrk/1", + label: "mac-mini-1", + }), + ], + "wrk/1/dev_1/quarantined", + ); + expect(shownOf(ofDevice).about).toEqual({ + name: "mac-mini-1", + open: "Open worker", + to: "/fleet/wrk%2F1", + }); }); }); From 0153b165ed1e89830644ce552c2cd6e244c24e4e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:31:35 +0200 Subject: [PATCH 18/18] fix: the daemon manual names protocol 59, and a test pins that Needs you reads only the inbox (#915) --- src/daemon/AGENTS.md | 2 +- ui/src/model/overview.test.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index 22034cb85..1d700443c 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -58,7 +58,7 @@ Its stdout lines move the state; a failed line, a non-zero exit or a failed laun 2. **Hello** — first frame must be `hello`, else `HANDSHAKE_REQUIRED` and close. Role: the uplink connection is `admin` by acceptance; otherwise the resolver. Principal: `hello.principal`, else `SIMLOCK_AGENT_ID`, else the daemon's pid. Both are fixed for the connection's life. 3. **Version** — ranges overlap → highest common version. `PROTOCOL_VERSION_RANGE` - is `{ min: 58, max: 58 }` (`src/contract/protocol.ts`). No overlap → the + is `{ min: 59, max: 59 }` (`src/contract/protocol.ts`). No overlap → the connection stays open, and every request but `daemon.stop` gets `PROTOCOL_VERSION_UNSUPPORTED`. 4. **Request** — one frame → one `dispatch()` call. While stopping: `DAEMON_STOPPING`. diff --git a/ui/src/model/overview.test.ts b/ui/src/model/overview.test.ts index de3ee7189..82b0805fb 100644 --- a/ui/src/model/overview.test.ts +++ b/ui/src/model/overview.test.ts @@ -143,7 +143,7 @@ describe("the overview", () => { ]; const { inbox } = overviewOf({ items: inboxItems(workers, { leases: [lease], now: NOW }), - leases: [lease], + leases: [], now: NOW, requests: [], workers, @@ -156,6 +156,20 @@ describe("the overview", () => { ["disconnected", "error", "down-2", "/inbox/down-2%2Fdisconnected"], ["disconnected", "error", "down-3", "/inbox/down-3%2Fdisconnected"], ]); + + // why: the Needs you panel reads only /v1/inbox and /v1/workers; the Waiting and Ending soon + // tiles read requests and leases, so a lease about to end or a queued request must not move it. + const expiring = { ...lease, ttlDeadline: NOW + 1_000 }; + const request = { createdAt: NOW - 5_000 } as unknown as WaitingRequest; + const items = inboxItems(workers, { leases: [lease], now: NOW }); + const withOthers = overviewOf({ + items, + leases: [expiring], + now: NOW, + requests: [request], + workers, + }); + expect(withOthers.inbox).toEqual(inbox); }); it("gives each worker the row Fleet draws: its health, its slots and its use, and a lost worker no use", () => {