Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b482190
test: inbox.get answers the items the fleet's views, leases, waiting …
V3RON Oct 23, 2026
3081b59
feat(gateway): inbox.get answers a gateway's worker items (#915)
V3RON Oct 23, 2026
c42c4a6
test: the inbox rules turn fleet data into items (#915)
V3RON Oct 23, 2026
7c831bd
feat(contract): the inbox rules and their helpers live in one place (…
V3RON Oct 23, 2026
d49ff70
test: inbox.get reads the fleet's leases, waiting requests and tokens…
V3RON Oct 23, 2026
0934b1b
feat(gateway): inbox.get reads the fleet's leases, waiting requests a…
V3RON Oct 23, 2026
e17efd7
test: inbox.get on a worker answers a lease about to end and a reques…
V3RON Oct 23, 2026
32814fe
feat(daemon): inbox.get on a worker reads its own view, leases and qu…
V3RON Oct 23, 2026
e033f27
test: GET /v1/inbox dispatches inbox.get for the caller and answers i…
V3RON Oct 23, 2026
abf7ac3
feat(http): GET /v1/inbox (#915)
V3RON Oct 23, 2026
63657de
test: GET /v1/inbox on a worker answers an operator and refuses an ag…
V3RON Oct 23, 2026
3b84e34
test: the sidebar count, Inbox and Needs you show the server's items …
V3RON Oct 23, 2026
5daffb7
test: the console specs stub GET /v1/inbox (#915)
V3RON Oct 23, 2026
82144e4
docs: the Inbox is the daemon's, GET /v1/inbox (#915)
V3RON Oct 23, 2026
af13642
test: the console specs stub GET /v1/inbox (#915)
V3RON Oct 23, 2026
3c03e45
fix(daemon): the socket answers inbox.get (#915)
V3RON Oct 23, 2026
55c5789
test: the audit proves the Inbox rules' ties, holders, token store an…
V3RON Oct 23, 2026
0153b16
fix: the daemon manual names protocol 59, and a test pins that Needs …
V3RON Oct 23, 2026
0835927
Merge remote-tracking branch 'origin/task/914' into HEAD
V3RON Oct 23, 2026
49a4d05
merge: origin/task/914 into task/915
V3RON Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dependency-cruiser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
17 changes: 4 additions & 13 deletions e2e/console/actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
startFleet,
test,
} from "./fixtures.js";
import { addInboxItems, drainedWorkerItem } from "./inbox-stub.js";
import { answerWithoutLoad } from "./workers-answers.js";

const ONE_DEVICE = { limits: { ios: { maxDevices: 1, maxRunning: 1 }, maxRunning: 1 } };
Expand Down Expand Up @@ -671,19 +672,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<string, unknown>[] };
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);
Expand Down
83 changes: 83 additions & 0 deletions e2e/console/inbox-stub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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<string, unknown>;

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 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,
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<void> {
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,
});
},
);
}
148 changes: 20 additions & 128 deletions e2e/console/inbox.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -208,75 +209,32 @@ 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);

const needsALook = page.getByRole("region", { name: "Needs a look" });
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);

Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down
12 changes: 6 additions & 6 deletions e2e/console/overview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 } });
Expand All @@ -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 {
Expand Down
Loading
Loading