Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5d21e4a
test: ⋯ → Update… → Update sends {} and shows Updating <name> to <to>…
V3RON Oct 22, 2026
892fba2
feat(ui): a worker behind the latest offers Update… and the toast nam…
V3RON Oct 22, 2026
fcaa015
test: an action sends its body, write resolves the parsed answer (#839)
V3RON Oct 22, 2026
ae33e1a
feat(ui): a worker's version line shows beside its page title (#839)
V3RON Oct 22, 2026
fa6501c
test: Update… and Update now are offered as the table says (#839)
V3RON Oct 22, 2026
b7cbd63
feat(ui): Update… and Update now by the table, in menus and the palet…
V3RON Oct 22, 2026
390d5e2
test: the search lists the update actions in its own order (#839)
V3RON Oct 22, 2026
2681a7c
test: the Update… dialog words its title and body from the worker's s…
V3RON Oct 22, 2026
96d2fdc
feat(ui): the Update… dialog words itself from the worker's leases (#…
V3RON Oct 22, 2026
257df29
test: the Update now dialog counts the update's leases (#839)
V3RON Oct 22, 2026
40d2ae5
feat(ui): the Update now dialog counts the update's leases (#839)
V3RON Oct 22, 2026
b14dd43
test: the console words the daemon's answer to an update (#839)
V3RON Oct 22, 2026
af82efd
feat(ui): the toast words started, following and upToDate answers (#839)
V3RON Oct 22, 2026
9704819
test: a worker's row reads updating with its stage (#839)
V3RON Oct 22, 2026
a277884
feat(ui): the Fleet row reads updating with its stage (#839)
V3RON Oct 22, 2026
fbc6c20
test: a worker page words its update banners (#839)
V3RON Oct 22, 2026
cdebd4a
feat(ui): the worker page shows the updating and failure banners with…
V3RON Oct 22, 2026
c1b2704
feat(ui): the Inbox holds an update failed item with Update… as its f…
V3RON Oct 22, 2026
317f161
test: the sidebar footer tells a gateway's operator when the gateway …
V3RON Oct 22, 2026
b3553ae
feat(ui): the sidebar footer tells a gateway's operator when the gate…
V3RON Oct 22, 2026
72d0bcd
test: a gateway's version facts are read every 10 minutes and kept wh…
V3RON Oct 22, 2026
ccefc3c
feat(ui): the console keeps the daemon's version facts and reads them…
V3RON Oct 22, 2026
a3bd191
test(ui): hold the stream so only the 10-minute timer re-reads the ve…
V3RON Oct 22, 2026
9dc7e42
feat(ui): Update now's toast says upToDate when the worker already ru…
V3RON Oct 22, 2026
32eed56
test: the Fleet row reads the stage and the failed page offers Update…
V3RON Oct 22, 2026
ec0297d
docs: the console's update notice, actions, banner and Inbox item (#839)
V3RON Oct 22, 2026
b645f5b
refactor(ui): split the action runner's toast and the Inbox's update-…
V3RON Oct 22, 2026
36d96e5
fix(ui): import the newer-version rule by its own path so the bundle …
V3RON Oct 22, 2026
131ba64
test: probe-driven gaps in the update tests, fleet-console page names…
V3RON Oct 22, 2026
d8aa5c2
test: fold the update toast cases into tables (#839)
V3RON Oct 22, 2026
8d62fb8
test: fold the update toast cases into one table (#839)
V3RON Oct 22, 2026
f0d2eb8
fix: record the past-index exemption's authorization, correct the Upd…
V3RON Oct 22, 2026
45c21fc
test: read the footer text without a regex tag strip (#839)
V3RON Oct 22, 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
10 changes: 9 additions & 1 deletion .dependency-cruiser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,22 @@ const components = findComponents("src")
.filter((dir) => dir !== "src")
.flatMap((dir) => [dir, `dist${dir.slice("src".length)}`]);

// 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.
const entryFiles = (component) =>
component === "src/contract"
? "index|testing|definition|version-order"
: "index|testing|definition";

const pastIndex = components.map((component) => ({
name: "past-index",
comment: `${component} is entered through its index`,
severity: "error",
from: { pathNot: `^${escape(component)}/` },
to: {
path: `^${escape(component)}/`,
pathNot: `^${escape(component)}/(index|testing|definition)\\.(ts|js)$`,
pathNot: `^${escape(component)}/(${entryFiles(component)})\\.(ts|js)$`,
},
}));

Expand Down
4 changes: 3 additions & 1 deletion docs/internal/agent-rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ grounds for rejecting a change even if it works.
through a third: a cycle means they are one module or a decision sits
in the wrong one; a unit test enforces it. The breaches that predate
the check are listed in `.dependency-cruiser-known-violations.json` and
`.dependency-cruiser-known-cycles.json`; those lists only shrink. A single-file
`.dependency-cruiser-known-cycles.json`; those lists only shrink. The one
authorized exemption is the console's import of `src/contract/version-order.ts`
by its own path (#825 Decisions, `.dependency-cruiser.mjs`). A single-file
component's surface is what the file exports, and a component whose
surface has grown past one file becomes a directory with an index, not
a second file others reach into. Each module's manual,
Expand Down
122 changes: 122 additions & 0 deletions e2e/console/update.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { Page } from "@playwright/test";

import { disposeFleet, expect, type RunningDaemon, signIn, startFleet, test } from "./fixtures.js";

const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] };

type WorkerFields = Record<string, unknown>;

// why: the real fleet never has a newer release to offer, so the list says what the update fields
// would, as actions.spec.ts does for `drained`.
async function showWorkerFields(page: Page, fields: () => WorkerFields): Promise<void> {
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, ...fields() })) },
response,
});
},
);
}

async function openFleet(page: Page, gateway: RunningDaemon): Promise<void> {
await page.goto(new URL("/fleet", gateway.url).toString());
await signIn(page, gateway.tokens.operator);
await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible();
}

test.describe("the console updates a worker", () => {
test("Update… sends an empty body and the toast names the version", async ({ page }) => {
const fleet = await startFleet([{ driverScript: { ios: IOS }, label: "upd-a" }]);
try {
await showWorkerFields(page, () => ({
latestVersion: "0.4.0",
lastUpdateFailure: null,
update: null,
version: "0.3.0",
}));
await page.route(
(url) => url.pathname === "/v1/status",
async (route) => {
const response = await route.fetch();
const body = (await response.json()) as { daemon: Record<string, unknown> };
await route.fulfill({
json: { ...body, daemon: { ...body.daemon, latestVersion: "0.4.0", version: "0.3.0" } },
response,
});
},
);
const posts: string[] = [];
await page.route("**/v1/workers/*/update", async (route) => {
posts.push(route.request().postData() ?? "");
await route.fulfill({
json: {
started: true,
upToDate: false,
update: { stage: "waiting", leases: 0, from: "0.3.0", to: "0.4.0" },
version: "0.3.0",
},
status: 202,
});
});
await openFleet(page, fleet.gateway);
const row = page.locator("main tbody tr").filter({ hasText: "upd-a" });
await expect(row).toContainText("simlock 0.3.0 · 0.4.0 available");
await expect(page.locator(".update-notice")).toHaveText("simlock 0.3.0 · 0.4.0 available");
await row.getByRole("button", { name: "Actions for upd-a" }).click();
await page.getByRole("menuitem", { name: "Update…" }).click();
const dialog = page.getByRole("alertdialog", { name: "Update upd-a from 0.3.0 to 0.4.0?" });
await expect(dialog).toContainText(
"It has no leases, so it installs at once. It restarts by itself.",
);
await dialog.getByRole("button", { name: "Update", exact: true }).click();
await expect(page.getByText("Updating upd-a to 0.4.0")).toBeVisible();
expect(posts).toEqual(["{}"]);
} finally {
await disposeFleet(fleet);
}
});

test("Update now → Install now sends force, a 200 upToDate answer names the version, and a 409 shows its message", async ({
page,
}) => {
const fleet = await startFleet([{ driverScript: { ios: IOS }, label: "upd-b" }]);
try {
await showWorkerFields(page, () => ({
latestVersion: "0.4.0",
lastUpdateFailure: null,
update: { stage: "waiting", leases: 2, from: "0.3.0", to: "0.4.0" },
version: "0.3.0",
}));
const answers = [
{ json: { started: false, upToDate: true, update: null, version: "0.4.0" }, status: 200 },
{
json: { error: { code: "CONFLICT", message: "Another update is running." } },
status: 409,
},
];
const posts: string[] = [];
await page.route("**/v1/workers/*/update", async (route) => {
posts.push(route.request().postData() ?? "");
await route.fulfill(answers[posts.length - 1] ?? { status: 500 });
});
await openFleet(page, fleet.gateway);
const row = page.locator("main tbody tr").filter({ hasText: "upd-b" });
const toasts = page.getByRole("region", { name: "Notifications" });
for (const toast of ["upd-b already runs 0.4.0", "Another update is running."]) {
await row.getByRole("button", { name: "Actions for upd-b" }).click();
await page.getByRole("menuitem", { name: "Update now" }).click();
const dialog = page.getByRole("alertdialog", { name: "Install 0.4.0 on upd-b now?" });
await expect(dialog).toContainText("Its 2 leases keep running through the restart.");
await dialog.getByRole("button", { name: "Install now" }).click();
await expect(toasts.getByText(toast, { exact: true })).toBeVisible();
}
expect(posts).toEqual(['{"force":true}', '{"force":true}']);
} finally {
await disposeFleet(fleet);
}
});
});
9 changes: 7 additions & 2 deletions e2e/console/workers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,16 +293,21 @@ function expectedRow(view: WorkerView): {
readonly bars: number;
} {
const name = view.label ?? view.id;
const system = [
view.host === undefined ? "" : `${view.host.os} ${view.host.osVersion}`,
view.version === undefined ? "" : `simlock ${view.version}`,
]
.filter((part) => part !== "")
.join(" · ");
if (view.connection === "incompatible") {
return {
bars: 0,
slots: "No devices",
text: [name, "incompatible", "", "—", "—", "—", "—", ""],
text: [`${name}${system}`, "incompatible", "", "—", "—", "—", "—", ""],
};
}
const limit = (view.capacity?.ios.limit ?? 0) + (view.capacity?.android.limit ?? 0);
const leased = view.leases.length;
const system = view.host === undefined ? "" : `${view.host.os} ${view.host.osVersion}`;
return {
bars: 3,
slots: leased === 0 ? `${limit} free` : `${leased} leased, ${limit - leased} free`,
Expand Down
28 changes: 14 additions & 14 deletions src/contract/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,20 @@ 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 |
| `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 |
| `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

Expand Down
11 changes: 7 additions & 4 deletions ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
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).
Surface: [console.mdx](../website/src/content/docs/docs/worker/console.mdx). Look and voice: [DESIGN.md](../docs/internal/DESIGN.md).

## Layers
Expand Down Expand Up @@ -48,9 +48,12 @@ asks it. The live connection asks `session.lost()` when it loses the stream, in
the same check: reconnect at once, back off as for an unreachable daemon, or stop while the session
ends. `model/proxy-refusal.ts` words a refusal.

`data/mode.ts` keeps the daemon's mode (`daemon.mode` of `GET /v1/status`, the one field the console
reads from that route) once the live connection starts and again after each reconnect; a read that
fails leaves none. `useDaemonMode` (`state/live.tsx`) gives it to state (ADR 0037 §4).
`data/mode.ts` keeps the daemon's mode, `daemon.version` and `daemon.latestVersion` (the fields of
`GET /v1/status` the console reads) once the live connection starts and again after each reconnect; a
read that fails leaves none. The same read repeats every 10 minutes (the daily check emits no event);
a failed repeat keeps the last values, and the timer stops with the live connection. `useDaemonMode`
and `useDaemonFacts` (`state/live.tsx`) give them to state and to the sidebar's update notice
(ADR 0037 §4, ADR 0042).

## Actions

Expand Down
82 changes: 82 additions & 0 deletions ui/src/app/shell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";

import type { DaemonFacts, DaemonMode } from "../data/mode";
import { inConsole } from "../state/test-support";
import { UpdateNotice } from "./shell";

function textOf(markup: string): string {
let text = "";
let inTag = false;
for (const char of markup) {
if (char === "<") inTag = true;
else if (char === ">") inTag = false;
else if (!inTag) text += char;
}
return text;
}

function footer(options: {
readonly mode: DaemonMode | undefined;
readonly daemon: DaemonFacts;
readonly collapsed?: boolean;
}): string {
return textOf(
inConsole(<UpdateNotice collapsed={options.collapsed ?? false} />, {
daemon: () => options.daemon,
mode: () => options.mode,
}),
);
}

describe("the sidebar footer", () => {
it("tells a gateway's operator its own latest version when it is newer, and nothing otherwise", () => {
const cases = [
{
name: "newer",
mode: "gateway",
daemon: { version: "0.3.0", latestVersion: "0.4.0" },
collapsed: false,
shown: "simlock 0.3.0 · 0.4.0 available",
},
{
name: "equal",
mode: "gateway",
daemon: { version: "0.4.0", latestVersion: "0.4.0" },
collapsed: false,
shown: "",
},
{
name: "none known",
mode: "gateway",
daemon: { version: "0.3.0", latestVersion: null },
collapsed: false,
shown: "",
},
{
name: "collapsed",
mode: "gateway",
daemon: { version: "0.3.0", latestVersion: "0.4.0" },
collapsed: true,
shown: "",
},
{
name: "worker",
mode: "worker",
daemon: { version: "0.3.0", latestVersion: "0.4.0" },
collapsed: false,
shown: "",
},
{
name: "mode unknown",
mode: undefined,
daemon: { version: "0.3.0", latestVersion: "0.4.0" },
collapsed: false,
shown: "",
},
] as const;

expect(cases.map((entry) => ({ name: entry.name, shown: footer(entry) }))).toEqual(
cases.map((entry) => ({ name: entry.name, shown: entry.shown })),
);
});
});
12 changes: 12 additions & 0 deletions ui/src/app/shell.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useEffect } from "react";

import { AppFrame, Button, CommandPalette, Icon, Sidebar, Status, StatusDot } from "../ds";
import { isBehind, versionLine } from "../model/versions";
import { NotFoundScreen } from "../screens/not-found";
import { useDaemonFacts, useDaemonMode } from "../state/live";
import { usePath } from "../state/router";
import { useSearchPalette, useSearchSession } from "../state/search";
import {
Expand Down Expand Up @@ -55,11 +57,21 @@ export function Shell() {
);
}

// why: the gateway's own version is the operator's to act on, so a worker's console and a
// collapsed sidebar stay quiet; the "newer" rule is the contract's, through `versionLine`.
export function UpdateNotice({ collapsed }: { readonly collapsed: boolean }) {
const mode = useDaemonMode();
const facts = { ...useDaemonFacts(), leased: 0, update: null };
if (collapsed || mode !== "gateway" || !isBehind(facts)) return null;
return <p className="update-notice">{versionLine(facts)}</p>;
}

function SidebarFoot({ collapsed }: { readonly collapsed: boolean }) {
const signedIn = useSignedIn();
const signOut = useSignOut();
return (
<>
<UpdateNotice collapsed={collapsed} />
<ConnectionStatus collapsed={collapsed} />
{signedIn.name === undefined || collapsed ? null : (
<p className="signed-in-as">Signed in as {signedIn.name}</p>
Expand Down
Loading
Loading