From eabd143d2005f7060306c4f070eaec76941d46cb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 09:47:13 +0200 Subject: [PATCH 01/22] test: simlock device add / remove: an admin enrolls and unenrolls physical devices (#437) 30 failing: every flow of e2e/physical-enrollment.test.ts fails on the unknown command `device`. The scripted-device script types are added beside it so the e2e typecheck holds. --- e2e/fake-driver/types.ts | 36 +- e2e/physical-enrollment.test.ts | 632 ++++++++++++++++++++++++++++++++ 2 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 e2e/physical-enrollment.test.ts diff --git a/e2e/fake-driver/types.ts b/e2e/fake-driver/types.ts index 4483b98db..a58129cf8 100644 --- a/e2e/fake-driver/types.ts +++ b/e2e/fake-driver/types.ts @@ -23,7 +23,9 @@ export type FakeDriverOperation = | "listComponents" | "removeComponent" /** The root re-proof `doctor --purge-orphans` takes before its first destroy. */ - | "revalidateRoot"; + | "revalidateRoot" + /** The read `device add` makes of a physical device: logged with the ID the operator typed. */ + | "inspectPhysical"; export type FakeDriverEstimateOperation = "provision" | "boot" | "reclaim"; @@ -35,6 +37,33 @@ export type FakeDriverErrorSpec = | { readonly type: "DriverCrashError"; readonly message?: string } | { readonly type: "generic"; readonly message?: string }; +/** Why a driver refuses to enrol a device; the core adds `enrolled-on-other-platform` itself. */ +export type FakeDriverRefusalReason = + | "not-connected" + | "network-only" + | "untrusted" + | "developer-mode-off" + | "os-too-old" + | "unsupported-kind" + | "locked" + | "unreadable" + | "virtual-device"; + +/** + * One physical device the fake driver can read. `ids` lists every ID the device answers to: the + * first is its canonical ID, the rest are aliases an operator may type. A `refusal` makes every + * one of those IDs refused with its reason and message. The script is re-read on every call, so + * a test can change a device's model, OS version, class or apps between two `device add` calls. + */ +export interface ScriptedPhysicalDevice { + readonly ids: readonly string[]; + readonly model?: string; + readonly osVersion?: string; + readonly class?: "phone" | "tablet"; + readonly apps?: readonly string[]; + readonly refusal?: { readonly reason: FakeDriverRefusalReason; readonly message: string }; +} + export type ScriptedRunState = "running" | "stopped" | "transitioning"; export interface ScriptedMark { @@ -120,6 +149,11 @@ export interface FakeDriverPlatformScript { readonly reclaimStrategy?: "erase" | "snapshot" | "wipe"; readonly failures?: Partial>; readonly managedReality?: ScriptedManagedReality; + /** + * The physical devices `inspectPhysical` reads. An ID no entry lists is refused + * `not-connected`, as a device that is not plugged in is. + */ + readonly physicalDevices?: readonly ScriptedPhysicalDevice[]; /** * Environment the grant for this platform's devices should carry, standing in for the * device-set path / adb port a real driver contributes. Read on every operation like diff --git a/e2e/physical-enrollment.test.ts b/e2e/physical-enrollment.test.ts new file mode 100644 index 000000000..5d925f66f --- /dev/null +++ b/e2e/physical-enrollment.test.ts @@ -0,0 +1,632 @@ +import { describe, expect, it } from "vitest"; + +import type { FakeDriverRefusalReason, ScriptedPhysicalDevice } from "./fake-driver/types.js"; +import { freeLoopbackPort, waitFor, withDaemon, type TestEnv } from "./helpers/index.js"; + +/** + * `simlock device add` and `device remove` (ADR 0022 §2, §6): an admin enrols and unenrols a + * physical device on the host it is plugged into. Only the fake driver reads a device here; the + * scripted devices below are what its `inspectPhysical` answers from. + */ + +const UDID = "00008110-001A2B3C4D5E"; +const ALIAS = "7A1C0E52-6B7D-4F1E-9C1A-2D3E4F5A6B7C"; +const APPS = Array.from({ length: 12 }, (_, index) => `com.example.app${String(index)}`); + +const IPHONE: ScriptedPhysicalDevice = { + apps: APPS, + class: "phone", + ids: [UDID, ALIAS], + model: "iPhone 15 Pro", + osVersion: "18.4", +}; + +const PIXEL: ScriptedPhysicalDevice = { + apps: ["com.example.mail"], + class: "phone", + ids: ["R58M123ABC"], + model: "Pixel 9", + osVersion: "15", +}; + +const IOS_BASE = { + availableOsVersions: ["18.4"], + defaultModels: { phone: ["iPhone 16"] }, + knownModels: ["iPhone 16", "iPhone 15 Pro"], + modelClasses: { "iPhone 15 Pro": "phone", "iPhone 16": "phone" }, +} as const; + +const ANDROID_BASE = { + availableOsVersions: ["35"], + defaultModels: { phone: ["Pixel 9"] }, + knownModels: ["Pixel 9"], + modelClasses: { "Pixel 9": "phone" }, +} as const; + +const REFUSALS: readonly FakeDriverRefusalReason[] = [ + "not-connected", + "network-only", + "untrusted", + "developer-mode-off", + "os-too-old", + "unsupported-kind", + "locked", + "unreadable", + "virtual-device", +]; + +interface Row { + readonly id: string; + readonly driverDeviceId?: string; + readonly state: string; + readonly physical: boolean; + readonly class?: string; + readonly workerId?: string; + readonly spec: { readonly model: string; readonly platform: string; readonly osVersion: string }; +} + +interface Added { + readonly id: string; + readonly driverDeviceId: string; + readonly platform: string; + readonly model: string; + readonly osVersion: string; + readonly class: string; + readonly appCount: number; + readonly reenrolled: boolean; +} + +async function startDaemon( + options: { + readonly ios?: Record; + readonly android?: Record; + readonly platforms?: readonly ("ios" | "android")[]; + readonly http?: number; + } = {}, +): Promise { + return withDaemon({ + ...(options.platforms === undefined ? {} : { fakeDriverPlatforms: options.platforms }), + ...(options.http === undefined + ? {} + : { configOverrides: { http: { enabled: true, host: "127.0.0.1", port: options.http } } }), + driverScript: { + android: { ...ANDROID_BASE, ...options.android }, + ios: { ...IOS_BASE, ...options.ios }, + }, + }); +} + +async function rows(env: TestEnv): Promise { + const result = await env.cli(["list", "--devices"]); + expect(result.code, result.stderr).toBe(0); + return result.json as Row[]; +} + +async function inspectCalls(env: TestEnv): Promise { + return (await env.driverLog.calls()).filter((call) => call.operation === "inspectPhysical"); +} + +async function named(env: TestEnv, ...names: string[]) { + return (await env.events()).filter((entry) => names.includes(entry.event)); +} + +async function add(env: TestEnv, platform: string, id: string): Promise { + const result = await env.cli(["device", "add", "--platform", platform, id, "--json"]); + expect(result.code, result.stderr).toBe(0); + return result.json as Added; +} + +async function leaseGrant( + env: TestEnv, + platform: string, + agent: string, +): Promise<{ device: { driverDeviceId: string; address: string } }> { + const result = await env.cli(["lease", "--platform", platform, "--agent-id", agent, "--detach"]); + expect(result.code, result.stderr).toBe(0); + return result.json as { device: { driverDeviceId: string; address: string } }; +} + +interface HttpDaemon { + readonly env: TestEnv; + readonly base: string; +} + +async function httpDaemon(options: { + readonly ios?: Record; + readonly android?: Record; +}): Promise { + const port = await freeLoopbackPort(); + const env = await startDaemon({ ...options, http: port }); + const base = `http://127.0.0.1:${String(port)}`; + await waitFor( + async () => { + try { + return (await fetch(`${base}/v1/healthz`)).ok; + } catch { + return false; + } + }, + { label: "HTTP API accepting connections" }, + ); + return { base, env }; +} + +async function bearer(env: TestEnv, role: "agent" | "operator"): Promise> { + const created = await env.cli(["token", "create", "--role", role]); + expect(created.code, created.stderr).toBe(0); + return { + authorization: `Bearer ${(created.json as { secret: string }).secret}`, + "content-type": "application/json", + }; +} + +interface ErrorBody { + readonly error: { + readonly code: string; + readonly message: string; + readonly id?: string; + readonly platform?: string; + readonly reason?: string; + readonly canonicalId?: string; + }; +} + +async function postDevice( + daemon: HttpDaemon, + body: unknown, +): Promise<{ status: number; body: ErrorBody }> { + const response = await fetch(`${daemon.base}/v1/devices`, { + body: JSON.stringify(body), + headers: await bearer(daemon.env, "operator"), + method: "POST", + }); + return { body: (await response.json()) as ErrorBody, status: response.status }; +} + +describe("simlock device add", () => { + it("of a scripted device exits 0 and prints one line with its model, OS version, canonical ID and app count, and no again", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + + const result = await env.cli(["device", "add", "--platform", "ios", UDID]); + + expect(result.code, result.stderr).toBe(0); + expect(result.stdout).toBe( + `Enrolled iPhone 15 Pro (iOS 18.4) as ${UDID}, 12 user apps recorded.\n`, + ); + }); + + it("lists the device ready and physical with its class, and records one device.enrolled with reenrolled false and the app count", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + await add(env, "ios", UDID); + + const status = await env.cli(["status", "--json"]); + expect(status.code, status.stderr).toBe(0); + const devices = (status.json as { devices: Row[] }).devices; + expect(devices).toHaveLength(1); + expect(devices[0]).toMatchObject({ class: "phone", physical: true, state: "ready" }); + + const enrolled = await named(env, "device.enrolled"); + expect(enrolled).toHaveLength(1); + expect(enrolled[0]?.payload).toMatchObject({ + appCount: 12, + class: "phone", + driverDeviceId: UDID, + model: "iPhone 15 Pro", + osVersion: "18.4", + platform: "ios", + reenrolled: false, + }); + }); + + it("with --json prints the registry ID, canonical ID, model, OS version, class, app count and reenrolled", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + + const added = await add(env, "ios", UDID); + + expect(added).toEqual({ + appCount: 12, + class: "phone", + driverDeviceId: UDID, + id: expect.stringMatching(/^dev_/), + model: "iPhone 15 Pro", + osVersion: "18.4", + platform: "ios", + reenrolled: false, + }); + expect((await rows(env)).map((row) => row.id)).toEqual([added.id]); + }); + + it("naming an enrolled device's alias keeps its registry ID, prints its line with again, emits device.enrolled with reenrolled true, and list --devices holds one non-deleted record with that canonical ID", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + const first = await add(env, "ios", UDID); + + const again = await env.cli(["device", "add", "--platform", "ios", ALIAS]); + + expect(again.code, again.stderr).toBe(0); + expect(again.stdout).toBe( + `Enrolled iPhone 15 Pro (iOS 18.4) as ${UDID} again, 12 user apps recorded.\n`, + ); + const second = await add(env, "ios", ALIAS); + expect(second).toMatchObject({ driverDeviceId: UDID, id: first.id, reenrolled: true }); + const enrolled = await named(env, "device.enrolled"); + expect(enrolled.map((entry) => (entry.payload as { reenrolled: boolean }).reenrolled)).toEqual([ + false, + true, + true, + ]); + const live = (await rows(env)).filter( + (row) => row.driverDeviceId === UDID && row.state !== "deleted", + ); + expect(live.map((row) => row.id)).toEqual([first.id]); + }); + + it.each(REFUSALS)( + "of a device the driver refuses %s answers DEVICE_NOT_ENROLLABLE with that reason, the driver's message and the ID as typed, exits 12, and writes no record and no device.enrolled", + async (reason) => { + const daemon = await httpDaemon({ + ios: { + physicalDevices: [ + { ids: ["REFUSED-1"], refusal: { message: `scripted ${reason} refusal`, reason } }, + ], + }, + }); + + const result = await daemon.env.cli(["device", "add", "--platform", "ios", "REFUSED-1"]); + const http = await postDevice(daemon, { id: "REFUSED-1", platform: "ios" }); + + expect(result.code, result.stderr).toBe(12); + expect(result.error).toEqual({ + code: "DEVICE_NOT_ENROLLABLE", + message: `scripted ${reason} refusal`, + }); + expect(http.status).toBe(422); + expect(http.body.error).toEqual({ + code: "DEVICE_NOT_ENROLLABLE", + id: "REFUSED-1", + message: `scripted ${reason} refusal`, + platform: "ios", + reason, + }); + expect(await rows(daemon.env)).toEqual([]); + expect(await named(daemon.env, "device.enrolled")).toEqual([]); + }, + ); + + it("of an ID no scripted device lists answers DEVICE_NOT_ENROLLABLE not-connected", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + + const result = await env.cli(["device", "add", "--platform", "ios", "NOT-PLUGGED-IN"]); + + expect(result.code, result.stderr).toBe(12); + expect(result.error?.code).toBe("DEVICE_NOT_ENROLLABLE"); + expect(await rows(env)).toEqual([]); + }); + + it("naming a Simlock simulator's driverDeviceId, or an emulator's driverDeviceId and, separately, its address, answers virtual-device and the driver log has no inspectPhysical call", async () => { + const env = await startDaemon({}); + const simulator = await leaseGrant(env, "ios", "sim-agent"); + const emulator = await leaseGrant(env, "android", "emu-agent"); + await env.driverLog.clear(); + + const refusals: { platform: string; id: string }[] = [ + { id: simulator.device.driverDeviceId, platform: "ios" }, + { id: emulator.device.driverDeviceId, platform: "android" }, + { id: emulator.device.address, platform: "android" }, + ]; + for (const { id, platform } of refusals) { + const result = await env.cli(["device", "add", "--platform", platform, id]); + expect(result.code, `${id}: ${result.stderr}`).toBe(12); + expect(result.error?.code).toBe("DEVICE_NOT_ENROLLABLE"); + } + const detail = await env.cli([ + "device", + "add", + "--platform", + "ios", + simulator.device.driverDeviceId, + "--json", + ]); + expect(detail.error?.message).toContain("Simlock"); + expect(await inspectCalls(env)).toEqual([]); + }); + + it("of an ID enrolled as iOS, for --platform android, answers enrolled-on-other-platform with canonicalId set and leaves the iOS record as it was", async () => { + const daemon = await httpDaemon({ + android: { physicalDevices: [{ ...PIXEL, ids: [UDID] }] }, + ios: { physicalDevices: [IPHONE] }, + }); + const enrolled = await add(daemon.env, "ios", UDID); + + const result = await daemon.env.cli(["device", "add", "--platform", "android", UDID]); + const http = await postDevice(daemon, { id: UDID, platform: "android" }); + + expect(result.code, result.stderr).toBe(12); + expect(result.error?.code).toBe("DEVICE_NOT_ENROLLABLE"); + expect(http.status).toBe(422); + expect(http.body.error).toMatchObject({ + canonicalId: UDID, + code: "DEVICE_NOT_ENROLLABLE", + id: UDID, + platform: "android", + reason: "enrolled-on-other-platform", + }); + const records = await rows(daemon.env); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + driverDeviceId: UDID, + id: enrolled.id, + spec: { platform: "ios" }, + state: "ready", + }); + }); + + it("without --platform exits 2 with USAGE and the driver log has no inspectPhysical call", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + await env.driverLog.clear(); + + const result = await env.cli(["device", "add", UDID]); + + expect(result.code, result.stderr).toBe(2); + expect(result.error?.code).toBe("USAGE"); + expect(result.error?.message).toContain("--platform"); + expect(await inspectCalls(env)).toEqual([]); + }); + + it("on a daemon with no Android driver answers NO_DRIVER for --platform android", async () => { + const env = await startDaemon({ platforms: ["ios"] }); + + const result = await env.cli(["device", "add", "--platform", "android", "R58M123ABC"]); + + expect(result.code, result.stderr).toBe(12); + expect(result.error?.code).toBe("NO_DRIVER"); + }); + + it("from an agent session answers FORBIDDEN, the driver log has no inspectPhysical call and the record is unchanged", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + const enrolled = await add(env, "ios", UDID); + await env.driverLog.clear(); + + const added = await env.cli([ + "--token", + "not-an-admin-credential", + "device", + "add", + "--platform", + "ios", + ALIAS, + ]); + const removed = await env.cli(["--token", "not-an-admin-credential", "device", "remove", UDID]); + + for (const result of [added, removed]) { + expect(result.code, result.stderr).toBe(1); + expect(result.error?.code).toBe("FORBIDDEN"); + } + expect(await inspectCalls(env)).toEqual([]); + const records = await rows(env); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ id: enrolled.id, state: "ready" }); + expect(await named(env, "device.deleted")).toEqual([]); + }); +}); + +describe("simlock device remove", () => { + it("of a ready device exits 0 and prints one line saying it is no longer enrolled and nothing on the device changed, lists the record deleted, records device.deleted with initiator operator and physical true, and makes no driver call after it", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + const enrolled = await add(env, "ios", UDID); + await env.driverLog.clear(); + + const result = await env.cli(["device", "remove", UDID]); + + expect(result.code, result.stderr).toBe(0); + expect(result.stdout).toBe( + `Removed ${UDID}. It is no longer enrolled; nothing on the device changed.\n`, + ); + expect((await rows(env)).find((row) => row.id === enrolled.id)?.state).toBe("deleted"); + const deleted = await named(env, "device.deleted"); + expect(deleted).toHaveLength(1); + expect(deleted[0]?.payload).toEqual({ + deviceId: enrolled.id, + initiator: "operator", + physical: true, + }); + expect(await env.driverLog.calls()).toEqual([]); + }); + + it("of an ID with no record, and of a removed device, answers UNKNOWN_DEVICE and exits 12", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + + const never = await env.cli(["device", "remove", UDID]); + await add(env, "ios", UDID); + expect((await env.cli(["device", "remove", UDID])).code).toBe(0); + const twice = await env.cli(["device", "remove", UDID]); + + for (const result of [never, twice]) { + expect(result.code, result.stderr).toBe(12); + expect(result.error?.code).toBe("UNKNOWN_DEVICE"); + } + }); + + it("from a non-interactive shell, with no --yes, unenrols the device", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + const enrolled = await add(env, "ios", UDID); + + const result = await env.cli(["device", "remove", UDID]); + + expect(result.code, result.stderr).toBe(0); + expect((await rows(env)).find((row) => row.id === enrolled.id)?.state).toBe("deleted"); + }); + + it("then device add writes a new record with a new registry ID and reenrolled false, and the old record stays deleted", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + const first = await add(env, "ios", UDID); + expect((await env.cli(["device", "remove", UDID])).code).toBe(0); + + const second = await add(env, "ios", UDID); + + expect(second.id).not.toBe(first.id); + expect(second.reenrolled).toBe(false); + const byId = new Map((await rows(env)).map((row) => [row.id, row.state])); + expect(byId.get(first.id)).toBe("deleted"); + expect(byId.get(second.id)).toBe("ready"); + }); +}); + +describe("an enrolled physical device", () => { + it("is not granted to a lease --platform ios: the lease gets a simulator", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + const enrolled = await add(env, "ios", UDID); + + const grant = await env.cli(["lease", "--platform", "ios", "--agent-id", "sim", "--detach"]); + + expect(grant.code, grant.stderr).toBe(0); + const device = (grant.json as { device: { id: string; physical: boolean } }).device; + expect(device.physical).toBe(false); + expect(device.id).not.toBe(enrolled.id); + }); + + it("shows its canonical ID as driverDeviceId in status --json from an operator and from an agent session and in list --devices, and a simulator's status entry has none", async () => { + const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + await add(env, "ios", UDID); + const simulator = await leaseGrant(env, "ios", "sim-agent"); + + const operator = await env.cli(["status", "--json"]); + const agent = await env.cli(["--token", "not-an-admin-credential", "status", "--json"]); + + for (const status of [operator, agent]) { + expect(status.code, status.stderr).toBe(0); + const devices = (status.json as { devices: Row[] }).devices; + const physical = devices.find((device) => device.physical); + const virtual = devices.find((device) => !device.physical); + expect(physical?.driverDeviceId).toBe(UDID); + expect(virtual).toBeDefined(); + expect(virtual?.driverDeviceId).toBeUndefined(); + } + const listed = (await rows(env)).find((row) => row.physical); + expect(listed?.driverDeviceId).toBe(UDID); + expect(simulator.device.driverDeviceId).not.toBe(UDID); + }); +}); + +describe("device add over HTTP", () => { + it("POST /v1/devices with an operator token enrols the device; with an agent token it is 403 FORBIDDEN and no inspectPhysical call is made; a body with a worker field or any other unknown field is 400 BAD_REQUEST", async () => { + const { base, env } = await httpDaemon({ ios: { physicalDevices: [IPHONE] } }); + const agent = await bearer(env, "agent"); + const operator = await bearer(env, "operator"); + const post = (headers: Record, body: unknown) => + fetch(`${base}/v1/devices`, { body: JSON.stringify(body), headers, method: "POST" }); + + const refused = await post(agent, { id: UDID, platform: "ios" }); + expect(refused.status).toBe(403); + expect(((await refused.json()) as { error: { code: string } }).error.code).toBe("FORBIDDEN"); + expect(await inspectCalls(env)).toEqual([]); + + for (const extra of [{ worker: "w1" }, { nickname: "mine" }]) { + const bad = await post(operator, { id: UDID, platform: "ios", ...extra }); + expect(bad.status).toBe(400); + expect(((await bad.json()) as { error: { code: string } }).error.code).toBe("BAD_REQUEST"); + } + expect(await inspectCalls(env)).toEqual([]); + + const accepted = await post(operator, { id: UDID, platform: "ios" }); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toMatchObject({ + appCount: 12, + driverDeviceId: UDID, + reenrolled: false, + }); + expect((await rows(env)).filter((row) => row.state === "ready")).toHaveLength(1); + }); + + it("DELETE /v1/devices/ with an operator token unenrols the device; with an agent token it is 403 FORBIDDEN and the record stays ready", async () => { + const { base, env } = await httpDaemon({ ios: { physicalDevices: [IPHONE] } }); + const enrolled = await add(env, "ios", UDID); + const agent = await bearer(env, "agent"); + const operator = await bearer(env, "operator"); + const remove = (headers: Record) => + fetch(`${base}/v1/devices/${encodeURIComponent(UDID)}`, { headers, method: "DELETE" }); + + const refused = await remove(agent); + expect(refused.status).toBe(403); + expect(((await refused.json()) as { error: { code: string } }).error.code).toBe("FORBIDDEN"); + expect((await rows(env)).find((row) => row.id === enrolled.id)?.state).toBe("ready"); + + const accepted = await remove(operator); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toEqual({ driverDeviceId: UDID, id: UDID }); + expect((await rows(env)).find((row) => row.id === enrolled.id)?.state).toBe("deleted"); + }); +}); + +describe("device add through a gateway", () => { + async function fleet(): Promise<{ gateway: TestEnv; worker: TestEnv; workerId: string }> { + const port = await freeLoopbackPort(); + const gateway = await withDaemon({ + configOverrides: { http: { enabled: true, host: "127.0.0.1", port }, mode: "gateway" }, + driver: "none", + }); + const minted = await gateway.cli(["token", "create", "--role", "worker"]); + expect(minted.code, minted.stderr).toBe(0); + const { secret } = minted.json as { secret: string }; + const worker = await withDaemon({ + configOverrides: { gateway: { label: "w1", token: secret, url: `ws://127.0.0.1:${port}` } }, + driverScript: { ios: { ...IOS_BASE, physicalDevices: [IPHONE] } }, + }); + let workerId = ""; + await waitFor( + async () => { + const listed = await gateway.cli(["worker", "list", "--json"]); + const workers = (listed.json as { workers?: { id: string; connection: string }[] } | null) + ?.workers; + const connected = workers?.find((entry) => entry.connection === "connected"); + workerId = connected?.id ?? ""; + return connected !== undefined; + }, + { label: "the worker is connected to the gateway" }, + ); + return { gateway, worker, workerId }; + } + + it("shows a device enrolled on worker w1's own daemon in the gateway's list --devices on w1 with its driverDeviceId", async () => { + const { gateway, worker, workerId } = await fleet(); + + const added = await worker.cli(["device", "add", "--platform", "ios", UDID]); + expect(added.code, added.stderr).toBe(0); + + await waitFor( + async () => { + const listed = await gateway.cli(["list", "--devices"]); + const device = (listed.json as Row[] | null)?.find((row) => row.physical); + return device?.workerId === workerId && device.driverDeviceId === UDID; + }, + { label: "the gateway lists the enrolled device on w1" }, + ); + }); + + it("answers device add and device remove with UNSUPPORTED_IN_GATEWAY_MODE and exit 2, saying to run the command on the worker the device is plugged into, and no worker's driver log has an inspectPhysical call", async () => { + const { gateway, worker } = await fleet(); + await worker.driverLog.clear(); + + const added = await gateway.cli(["device", "add", "--platform", "ios", UDID]); + const removed = await gateway.cli(["device", "remove", UDID]); + + for (const result of [added, removed]) { + expect(result.code, result.stderr).toBe(2); + expect(result.error?.code).toBe("UNSUPPORTED_IN_GATEWAY_MODE"); + expect(result.error?.message).toContain("worker the device is plugged into"); + } + expect(await inspectCalls(worker)).toEqual([]); + }); +}); + +describe("android enrolment", () => { + it("records the apps the driver lists and shows the device with its Android OS version", async () => { + const env = await startDaemon({ android: { physicalDevices: [PIXEL] } }); + + const result = await env.cli(["device", "add", "--platform", "android", "R58M123ABC"]); + + expect(result.code, result.stderr).toBe(0); + expect(result.stdout).toBe( + "Enrolled Pixel 9 (Android 15) as R58M123ABC, 1 user app recorded.\n", + ); + }); +}); From 3399a90ed4bed64e8288d03490cd08797e4fa7bc Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 09:55:25 +0200 Subject: [PATCH 02/22] feat(core): device add and device remove enrol and unenrol physical devices (#437) 30 failing -> implementation in place; unit tests follow. --- e2e/fake-driver/fake-driver.ts | 23 +++ src/admin/index.ts | 4 + src/bus/index.test.ts | 1 + src/bus/index.ts | 25 ++- src/cli/index.test.ts | 71 +++++++++ src/cli/index.ts | 84 ++++++++++ src/contract/errors.ts | 49 ++++++ src/contract/operations.ts | 60 +++++++ src/contract/protocol.ts | 6 +- src/contract/schemas.ts | 19 ++- src/core/create-core.ts | 6 + src/core/device-operation-claims.ts | 12 +- src/core/doctor.test.ts | 2 + src/core/driver.ts | 44 ++++++ src/core/fake-driver.ts | 53 ++++++- src/core/index.ts | 10 ++ src/core/physical-enrollment.ts | 201 ++++++++++++++++++++++++ src/core/quarantine-coordinator.test.ts | 9 +- src/core/quarantine-coordinator.ts | 59 +++++-- src/core/reclaim-coordinator.test.ts | 2 +- src/core/registry.ts | 156 +++++++++++++++++- src/core/warm-pool/converger.test.ts | 8 +- src/daemon/dispatcher.ts | 69 ++++++++ src/daemon/main.ts | 1 + src/daemon/server.ts | 7 + src/drivers/android/index.ts | 10 ++ src/drivers/ios/index.ts | 10 ++ src/gateway/dispatcher.ts | 9 ++ src/http/app.ts | 23 +++ src/leasing/create-leasing.test.ts | 6 +- src/simlock-client/client.ts | 34 ++++ src/simlock-client/types.ts | 4 + ui/src/views/worker-detail.tsx | 9 +- 33 files changed, 1052 insertions(+), 34 deletions(-) create mode 100644 src/core/physical-enrollment.ts diff --git a/e2e/fake-driver/fake-driver.ts b/e2e/fake-driver/fake-driver.ts index 3fb86d10f..d5a4a828f 100644 --- a/e2e/fake-driver/fake-driver.ts +++ b/e2e/fake-driver/fake-driver.ts @@ -25,6 +25,7 @@ import { type ObservedRunState, type PassthroughCommand, type PassthroughContext, + type PhysicalInspection, PassthroughRefusedError, removeListedComponent, type DeviceClass, @@ -474,6 +475,28 @@ export class OutOfProcessFakeDriver implements Driver { }; } + /** + * Reads the scripted physical device `id` names, from the script as it is now. An ID no entry + * lists is `not-connected`, as a device that is not plugged in is. + */ + async inspectPhysical(id: string): Promise { + const script = await this.#beforeCall("inspectPhysical", [id]); + const device = script.physicalDevices?.find((candidate) => candidate.ids.includes(id)); + if (device === undefined) { + return { enrollable: false, message: `${id} is not connected`, reason: "not-connected" }; + } + if (device.refusal !== undefined) return { enrollable: false, ...device.refusal }; + const [canonical] = device.ids; + return { + apps: [...(device.apps ?? [])], + class: device.class ?? "phone", + driverDeviceId: canonical ?? id, + enrollable: true, + model: device.model ?? "Fake Phone", + osVersion: device.osVersion ?? "1.0", + }; + } + /** Read straight from the script, not through `#beforeCall`: the daemon asks for these on * its own schedule, and a test's call log should hold only what the test caused. */ async toolVersions(): Promise { diff --git a/src/admin/index.ts b/src/admin/index.ts index 0b74a4869..5c29349f8 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -25,7 +25,11 @@ export type { ComponentInstallOutput, ComponentInstallProgress, DaemonStopOutput, + DeviceAddInput, + DeviceAddOutput, DeviceRecoveredPush, + DeviceRemoveInput, + DeviceRemoveOutput, DeviceUnhealthyPush, ExecInput, ExecOutput, diff --git a/src/bus/index.test.ts b/src/bus/index.test.ts index 5e9343bd2..292111b32 100644 --- a/src/bus/index.test.ts +++ b/src/bus/index.test.ts @@ -37,6 +37,7 @@ const EVENT_NAMES: Record = { "device.quarantine-stranded": true, "device.shutdown": true, "device.deleted": true, + "device.enrolled": true, "device.slimmed": true, "component.install-started": true, "component.installed": true, diff --git a/src/bus/index.ts b/src/bus/index.ts index e98723c4d..bd5b3ad8b 100644 --- a/src/bus/index.ts +++ b/src/bus/index.ts @@ -192,7 +192,30 @@ export interface EventMap { readonly deviceRoot: string; }; "device.shutdown": { readonly deviceId: string; readonly initiator: string }; - "device.deleted": { readonly deviceId: string; readonly initiator: string }; + /** + * Removed from the registry, and from disk for a virtual device (ADR 0022 §9). `physical` is + * the record's own flag; `initiator` is `operator` for a physical device unenrolled by + * `device remove`. + */ + "device.deleted": { + readonly deviceId: string; + readonly initiator: string; + readonly physical: boolean; + }; + /** + * An admin enrolled a physical device with `device add`, or enrolled one already enrolled + * again (`reenrolled`), which replaced the record's model, OS version, class and app list. + */ + "device.enrolled": { + readonly deviceId: string; + readonly platform: string; + readonly driverDeviceId: string; + readonly model: string; + readonly osVersion: string; + readonly class: "phone" | "tablet"; + readonly appCount: number; + readonly reenrolled: boolean; + }; "device.slimmed": { readonly deviceId: string; readonly address: string; diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index baf79faef..328a91d67 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1425,6 +1425,65 @@ describe("CLI: daemon status (ADR 0003 §11)", () => { }); }); +describe("CLI: device add and device remove (ADR 0022 §2)", () => { + async function runDevice(argv: string[]) { + const output = outputCapture(); + let connected = false; + const code = await runCli( + argv, + output.environmentWith({ + connectAdmin: async () => { + connected = true; + return fakeClient(); + }, + }), + ); + return { code, connected, stderr: output.stderr, stdout: output.stdout }; + } + + it.each([ + ["an unknown subcommand", ["device", "enroll", "X"]], + ["two IDs", ["device", "add", "--platform", "ios", "A", "B"]], + ["--yes, which remove does not take", ["device", "remove", "--yes", "X"]], + ["no --platform", ["device", "add", "X"]], + ])("exits 2 with USAGE and contacts nobody for %s", async (_case, argv) => { + const result = await runDevice(argv); + + expect(result.code).toBe(2); + expect(result.connected).toBe(false); + expect(JSON.parse(result.stderr)).toEqual({ + error: { code: "USAGE", message: expect.any(String) }, + }); + }); + + it("device --help prints both forms", async () => { + const result = await runDevice(["device", "--help"]); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("simlock device add --platform [--json]"); + expect(result.stdout).toContain("simlock device remove [--json]"); + }); + + it("prints one line for an enrollment, naming the platform's OS, and the JSON with --json", async () => { + const line = await runDevice(["device", "add", "--platform", "ios", "UDID-1"]); + const json = await runDevice(["device", "add", "--platform", "android", "SER-1", "--json"]); + + expect(line.stdout).toBe( + "Enrolled iPhone 15 Pro (iOS 18.4) as UDID-1, 2 user apps recorded.\n", + ); + expect(JSON.parse(json.stdout)).toMatchObject({ driverDeviceId: "SER-1", platform: "android" }); + }); + + it("prints one line for a removal, with no confirmation", async () => { + const result = await runDevice(["device", "remove", "UDID-1"]); + + expect(result.code).toBe(0); + expect(result.stdout).toBe( + "Removed UDID-1. It is no longer enrolled; nothing on the device changed.\n", + ); + }); +}); + describe("CLI: component remove (ADR 0010 §8)", () => { /** A client that records every removal it is asked for and answers it. */ function removingClient(asked: unknown[]) { @@ -5196,6 +5255,18 @@ function fakeClient(overrides: Partial = {}): SimlockAdminCl platform: input.platform, version: input.version, }), + addDevice: (input) => + Promise.resolve({ + appCount: 2, + class: "phone" as const, + driverDeviceId: input.id, + id: "dev_9", + model: "iPhone 15 Pro", + osVersion: "18.4", + platform: input.platform, + reenrolled: false, + }), + removeDevice: (input) => Promise.resolve({ driverDeviceId: input.id, id: input.id }), ...overrides, }; return base; diff --git a/src/cli/index.ts b/src/cli/index.ts index b7bbc672e..8e56d12d5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -75,6 +75,9 @@ Commands: List installed runtimes and system images component remove [--dry-run] [--yes] Remove a runtime or system image Simlock installed + device add --platform [--json] + Enrol a physical device plugged into this host + device remove [--json] Forget an enrolled physical device; the device is untouched simctl Run xcrun simctl against Simlock's iOS device set adb Run adb against Simlock's adb server mcp Start the stdio MCP server @@ -628,6 +631,8 @@ export async function runCli( return await runWorker(rest.slice(1), environment, token); case "component": return await runComponent(rest.slice(1), environment, token); + case "device": + return await runDevice(rest.slice(1), environment, token); case "simctl": case "adb": // Named here rather than discovered from the drivers on purpose: these are @@ -1945,6 +1950,85 @@ async function runWorkerAction( return 0; } +const DEVICE_USAGE = + "Usage: simlock device add --platform [--json]\n" + + " simlock device remove [--json]\n"; + +/** + * ADR 0022 §2: `device add` and `device remove` render the daemon's `device.add` and + * `device.remove` as one line, or with `--json` as the operation's output. Argument parsing and + * rendering only: what can be enrolled, and why not, is the platform driver's answer. `remove` + * asks for nothing: it changes nothing on the device (safety rule 5). + */ +async function runDevice( + argv: readonly string[], + environment: CliEnvironment, + token: string | undefined, +): Promise { + const command = argv[0]; + if (command === undefined || isHelp(command)) { + environment.stdout.write(DEVICE_USAGE); + return 0; + } + if (command !== "add" && command !== "remove") { + throw new UsageError(withHelpHint(`Unknown device command: ${command}`)); + } + const values = commandArgs(argv.slice(1), { + help: { type: "boolean", short: "h" }, + json: { type: "boolean" }, + ...(command === "add" ? { platform: { type: "string" as const } } : {}), + }); + if (values.help) { + environment.stdout.write(DEVICE_USAGE); + return 0; + } + const [id, ...extra] = values.positionals; + if (id === undefined || extra.length > 0) { + throw new UsageError(withHelpHint(`device ${command} takes exactly one `)); + } + const platform = values.platform; + if (command === "add" && platform !== "ios" && platform !== "android") { + throw new UsageError( + withHelpHint( + "device add needs --platform ios or --platform android, to say which driver reads the device", + ), + ); + } + const client = await connectDaemonClient(environment, token); + try { + if (command === "add") { + const result = await client.addDevice({ id, platform: platform as "ios" | "android" }); + environment.stdout.write( + values.json === true ? `${JSON.stringify(result)}\n` : `${describeEnrolled(result)}\n`, + ); + return 0; + } + const result = await client.removeDevice({ id }); + environment.stdout.write( + values.json === true + ? `${JSON.stringify(result)}\n` + : `Removed ${result.driverDeviceId}. It is no longer enrolled; nothing on the device changed.\n`, + ); + return 0; + } finally { + await client.close(); + } +} + +/** `Enrolled iPhone 15 Pro (iOS 18.4) as [ again], 12 user apps recorded.` */ +function describeEnrolled(result: { + readonly appCount: number; + readonly driverDeviceId: string; + readonly model: string; + readonly osVersion: string; + readonly platform: "ios" | "android"; + readonly reenrolled: boolean; +}): string { + const os = `${result.platform === "ios" ? "iOS" : "Android"} ${result.osVersion}`; + const apps = `${String(result.appCount)} user app${result.appCount === 1 ? "" : "s"}`; + return `Enrolled ${result.model} (${os}) as ${result.driverDeviceId}${result.reenrolled ? " again" : ""}, ${apps} recorded.`; +} + const COMPONENT_USAGE = "Usage: simlock component install [--worker ... | --all-workers]\n" + " simlock component list [--platform ]\n" + diff --git a/src/contract/errors.ts b/src/contract/errors.ts index 4961aa6e9..233c681b3 100644 --- a/src/contract/errors.ts +++ b/src/contract/errors.ts @@ -11,6 +11,22 @@ */ import type { DeviceClass, Platform } from "./schemas.js"; +/** Why a device cannot be enrolled (ADR 0022 §2); `DEVICE_NOT_ENROLLABLE`'s `reason`. */ +export const DEVICE_NOT_ENROLLABLE_REASONS = [ + "not-connected", + "network-only", + "untrusted", + "developer-mode-off", + "os-too-old", + "unsupported-kind", + "locked", + "unreadable", + "virtual-device", + "enrolled-on-other-platform", +] as const; + +export type DeviceNotEnrollableReason = (typeof DEVICE_NOT_ENROLLABLE_REASONS)[number]; + export type ErrorKind = "transport" | "protocol" | "domain"; /** Per-code `details` shapes. `Record` means "no details, an empty object". */ @@ -94,6 +110,25 @@ export interface ErrorDetailsMap { /** ADR 0010 §8: a `component.remove` while an install or removal runs or waits on the platform. */ COMPONENT_BUSY: Record; UNKNOWN_LEASE: { readonly leaseId: string }; + /** + * ADR 0022 §2: `device.add` of a device that cannot be enrolled. `id` is the ID as typed; + * `canonicalId` is set when the core knows it (`enrolled-on-other-platform`, and a refusal of + * the second inspection). The driver picks every reason but `enrolled-on-other-platform`, and + * `virtual-device` for Simlock's own simulators and emulators is the core's. + */ + DEVICE_NOT_ENROLLABLE: { + readonly platform: Platform; + readonly id: string; + readonly reason: DeviceNotEnrollableReason; + readonly canonicalId?: string; + }; + /** + * ADR 0022 §2: `device.add` or `device.remove` of a device something else holds: `leased`, a + * `reclaiming` device or one whose `reclaim` claim is held, or `busy` under another enrollment. + */ + DEVICE_BUSY: { readonly id: string; readonly reason: "leased" | "reclaiming" | "busy" }; + /** `device.remove` of an ID with no enrolled device: no record, or a `deleted` one. */ + UNKNOWN_DEVICE: { readonly id: string }; /** A `simlock ` verb the owning driver will not proxy (ADR 0001, decision 7). Carries * the tool so a caller can say which wrapper refused without re-parsing the message. */ PASSTHROUGH_REFUSED: { readonly tool: string }; @@ -204,6 +239,9 @@ const CODES_WITH_DECLARED_DETAILS_BY_CODE: Record DOWNLOADS_DISABLED: true, COMPONENT_IN_USE: true, UNKNOWN_LEASE: true, + DEVICE_NOT_ENROLLABLE: true, + DEVICE_BUSY: true, + UNKNOWN_DEVICE: true, PASSTHROUGH_REFUSED: true, UNKNOWN_PASSTHROUGH_TOOL: true, UNSUPPORTED_IN_GATEWAY_MODE: true, @@ -308,6 +346,17 @@ export const ERROR_TABLE: { readonly [Code in SimlockErrorCode]: ErrorTableEntry httpStatus: 422, }, UNKNOWN_LEASE: { code: "UNKNOWN_LEASE", kind: "domain", cliExitCode: 1, httpStatus: 404 }, + // A device that cannot be enrolled is a fact about the device, as `RUNTIME_MISSING` is about + // the host: 422 and 12. A busy device passes once what holds it ends, as `NO_CAPACITY` does: + // 409 and 11. An unknown device is the "what you named does not exist" class: 404 and 12. + DEVICE_NOT_ENROLLABLE: { + code: "DEVICE_NOT_ENROLLABLE", + kind: "domain", + cliExitCode: 12, + httpStatus: 422, + }, + DEVICE_BUSY: { code: "DEVICE_BUSY", kind: "domain", cliExitCode: 11, httpStatus: 409 }, + UNKNOWN_DEVICE: { code: "UNKNOWN_DEVICE", kind: "domain", cliExitCode: 12, httpStatus: 404 }, // Both are caller errors -- a refused verb and an unknown wrapper are things the request got // wrong, not states of the host -- so they take the usage exit code and a 4xx. PASSTHROUGH_REFUSED: { diff --git a/src/contract/operations.ts b/src/contract/operations.ts index a0108bd1a..be157b045 100644 --- a/src/contract/operations.ts +++ b/src/contract/operations.ts @@ -991,6 +991,64 @@ export const componentRemove = defineOperation({ }), }); +// ---- device.add, device.remove (ADR 0022 §2) ----------------------------------------------- + +/** The longest ID `device.add` and `device.remove` take: an ID is a claim, bounded before use. */ +const ENROLLED_DEVICE_ID_MAX_LENGTH = 200; + +/** An ID as an operator types it: no whitespace or control character, and not an option. */ +const enrollmentDeviceIdSchema = z + .string() + .min(1) + .max(ENROLLED_DEVICE_ID_MAX_LENGTH) + .refine( + (id) => [...id].every((character) => !/\s/u.test(character) && !isControlCharacter(character)), + "must not contain whitespace or control characters", + ) + .refine((id) => !id.startsWith("-"), "must not start with -"); + +function isControlCharacter(character: string): boolean { + const code = character.codePointAt(0) ?? 0; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); +} + +/** + * Enrolls a physical device plugged into the host this daemon runs on, admin-only. The driver + * reads the device; the record is written only if the driver says it can be enrolled. `id` in + * the output is the registry ID, `driverDeviceId` the canonical one. `reenrolled` is true when + * the device already had a record, which kept its registry ID and took a fresh read. + */ +// fallow-ignore-next-line unused-export -- consumed only through the OPERATIONS registry, not by name; still public contract surface. +export const deviceAdd = defineOperation({ + name: "device.add", + role: "admin", + effect: "write", + input: z.object({ platform: platformSchema, id: enrollmentDeviceIdSchema }).strict(), + output: z.object({ + id: z.string(), + driverDeviceId: z.string(), + platform: platformSchema, + model: z.string(), + osVersion: z.string(), + class: z.enum(["phone", "tablet"]), + appCount: z.number().int().nonnegative(), + reenrolled: z.boolean(), + }), +}); + +/** + * Forgets an enrolled physical device, admin-only. `id` is the canonical ID. Nothing is sent + * to the device. + */ +// fallow-ignore-next-line unused-export -- consumed only through the OPERATIONS registry, not by name; still public contract surface. +export const deviceRemove = defineOperation({ + name: "device.remove", + role: "admin", + effect: "write", + input: z.object({ id: enrollmentDeviceIdSchema }).strict(), + output: z.object({ id: z.string(), driverDeviceId: z.string() }), +}); + // ---- the full registry ---------------------------------------------------------------------- export const OPERATIONS = { @@ -1024,6 +1082,8 @@ export const OPERATIONS = { "component.list": componentList, "worker.install-component": workerInstallComponent, "component.remove": componentRemove, + "device.add": deviceAdd, + "device.remove": deviceRemove, } as const; export type OperationName = keyof typeof OPERATIONS; diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index 424a89005..8cdf4455e 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -29,7 +29,7 @@ const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().in * workers, so a worker without it must be `incompatible` rather than fail in the middle of a * relay. ADR 0014 gives every event envelope an `id` (taking it to 10): a worker's pushed events * without one would fail the gateway's schema, so a worker on 9 is `incompatible` instead. ADR 0009 §7 makes `atRamBudget` a required field of each platform's `status.get` - * capacity (taking it to 11), which a gateway routing on it depends on. ADR 0015 §3 makes `modelClasses` a required field of each platform's catalog (taking it to 12): a gateway reading a worker's catalog would fail its schema without it, so a worker on 11 is `incompatible`. ADR 0015 §4 makes `classDefaults` a required field of each platform's catalog too (taking it to 13), for the same reason: a worker on 12 is `incompatible`. ADR 0009 §6 makes `servesDefaultMode` a required field of each device in `status.get` (taking it to 16), which a gateway telling a warm hit for a request with no mode depends on: a worker on 15 is `incompatible`. ADR 0017 §4 and §8 make `warmPool.reserveRunning` a required field of `config.get`'s output (taking it to 17): a client parsing an older daemon's `config.get` would fail its schema without it, so a daemon on 16 is `incompatible`. While a daemon is `starting`, its `status.get` answers `daemon` and `host` alone, so `devices`, `leases`, `capacity` and `queueDepth` become optional (taking it to 18): a peer on 17 cannot parse that answer, and a gateway reading it from a worker on 17 would fail its schema, so a worker on 17 is `incompatible`. `warmPool.targets` and `warmPool.maxConcurrentBoots` are required fields of `config.get`'s output too (taking it to 19), for the same reason: a daemon on 18 is `incompatible`. `lease.request` takes `leaseId` and every lease record carries `idChosenByRequester` (ADR 0020, taking it to 21): a gateway never forwards a `leaseId` to a worker that would reject it, and reads the flag from every worker's lease list, so a worker on 20 is `incompatible`. `lease.request` takes `fleetRequestId`, which a gateway sets on every dispatch to mark it a probe (ADR 0021, taking it to 22): a worker that does not know the field would refuse the request, so a worker on 21 is `incompatible`. Every device a worker reports carries `physical` (ADR 0022 §1, taking it to 23): a gateway reads it from every worker's devices, and a device without it fails the gateway's schema, so a worker on 22 is `incompatible`. It only ever widens once a second version is actually kept alive side by side with the + * capacity (taking it to 11), which a gateway routing on it depends on. ADR 0015 §3 makes `modelClasses` a required field of each platform's catalog (taking it to 12): a gateway reading a worker's catalog would fail its schema without it, so a worker on 11 is `incompatible`. ADR 0015 §4 makes `classDefaults` a required field of each platform's catalog too (taking it to 13), for the same reason: a worker on 12 is `incompatible`. ADR 0009 §6 makes `servesDefaultMode` a required field of each device in `status.get` (taking it to 16), which a gateway telling a warm hit for a request with no mode depends on: a worker on 15 is `incompatible`. ADR 0017 §4 and §8 make `warmPool.reserveRunning` a required field of `config.get`'s output (taking it to 17): a client parsing an older daemon's `config.get` would fail its schema without it, so a daemon on 16 is `incompatible`. While a daemon is `starting`, its `status.get` answers `daemon` and `host` alone, so `devices`, `leases`, `capacity` and `queueDepth` become optional (taking it to 18): a peer on 17 cannot parse that answer, and a gateway reading it from a worker on 17 would fail its schema, so a worker on 17 is `incompatible`. `warmPool.targets` and `warmPool.maxConcurrentBoots` are required fields of `config.get`'s output too (taking it to 19), for the same reason: a daemon on 18 is `incompatible`. `lease.request` takes `leaseId` and every lease record carries `idChosenByRequester` (ADR 0020, taking it to 21): a gateway never forwards a `leaseId` to a worker that would reject it, and reads the flag from every worker's lease list, so a worker on 20 is `incompatible`. `lease.request` takes `fleetRequestId`, which a gateway sets on every dispatch to mark it a probe (ADR 0021, taking it to 22): a worker that does not know the field would refuse the request, so a worker on 21 is `incompatible`. Every device a worker reports carries `physical` (ADR 0022 §1, taking it to 23): a gateway reads it from every worker's devices, and a device without it fails the gateway's schema, so a worker on 22 is `incompatible`. `device.add` and `device.remove` are operations (ADR 0022 §2, taking it to 24): a client on 24 that sends one to a daemon on 23 fails `hello` with `PROTOCOL_VERSION_UNSUPPORTED` instead of the older daemon's `UNKNOWN_REQUEST`, and a status entry may carry `driverDeviceId` for a physical device, so a worker on 23 is `incompatible`. It only ever widens once a second version is actually kept alive side by side with the * first, which nothing here does. * * A client from before any of those changes simply does not overlap this daemon, and `hello` @@ -42,9 +42,9 @@ const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().in * The consequence ADR 0005 §31 names: a pre-0005 worker's uplink negotiates nothing, so its * gateway marks it `incompatible` -- with both ranges on the view -- and never dispatches to it. * A worker on 5 is marked `incompatible` the same way (ADR 0008 §10), and so is one on 7 - * (ADR 0007 §12), and so is one on 8 (ADR 0010 §9), one on 10 (ADR 0009 §7), one on 11 (ADR 0015 §3), one on 12 (ADR 0015 §4), one on 13 (ADR 0015 §1), one on 14 (ADR 0015 §2), one on 15 (ADR 0009 §6), and one on 16 (`warmPool.reserveRunning`), one on 17 (a starting `status.get`), one on 18 (`warmPool.targets`), and one on 19 (`status.get`'s `warmPool`), and one on 20 (`leaseId`), and one on 21 (`fleetRequestId`), and one on 22 (`physical`). + * (ADR 0007 §12), and so is one on 8 (ADR 0010 §9), one on 10 (ADR 0009 §7), one on 11 (ADR 0015 §3), one on 12 (ADR 0015 §4), one on 13 (ADR 0015 §1), one on 14 (ADR 0015 §2), one on 15 (ADR 0009 §6), and one on 16 (`warmPool.reserveRunning`), one on 17 (a starting `status.get`), one on 18 (`warmPool.targets`), and one on 19 (`status.get`'s `warmPool`), and one on 20 (`leaseId`), and one on 21 (`fleetRequestId`), and one on 22 (`physical`), and one on 23 (`device.add`). */ -export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 23, max: 23 }; +export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 24, max: 24 }; /** * The one protocol version that ever existed before ranges did. Used only to build the diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 5b299de1a..3861272e2 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -193,9 +193,11 @@ export const deviceRecordSchema = z * - `stalled`: the stall decoration, surfaced as "stalled": a caller waiting on capacity can see * that a slot is held by a transition that is not finishing. * - * What stays off, and why: `driverDeviceId` (the driver address of a device the caller does - * not hold is not actionable -- a non-owning agent cannot drive it, and a holder already gets - * it on their grant), `driverData` (opaque driver-private blob, unbounded contents), + * What stays off, and why: `driverDeviceId` of a virtual device (the driver address of a device + * the caller does not hold is not actionable -- a non-owning agent cannot drive it, and a holder + * already gets it on their grant). A physical device differs: its canonical ID is how an operator + * names it to `device remove`, and it names a device on the desk, not a driver address only its + * holder can use, so it is shown to every role. Also off: `driverData` (opaque driver-private blob, unbounded contents), * `createdAt`/`lastLeaseEndedAt` (internal bookkeeping timestamps the human formatter never * reads), `recoveringSince`/`recoveryAttempts` (crash-recovery bookkeeping -- `safety.md` rule * 2 scopes that privilege narrowly, and broadcasting its progress to every agent is not part of @@ -218,6 +220,8 @@ export const statusDeviceSchema = z mode: deviceModeSchema.optional(), /** Present exactly when `physical` is `true`. */ class: physicalClassSchema.optional(), + /** A physical device's canonical ID, the ID `device remove` takes; never set on a virtual one. */ + driverDeviceId: z.string().optional(), /** * ADR 0009 §6: whether the device's pool mode (its spec's mode, full when it names none) is the * worker's default mode for the device's platform -- the pool a request naming no mode draws @@ -246,7 +250,14 @@ export const statusDeviceSchema = z */ workerId: z.string().optional(), }) - .superRefine((device, context) => checkDeviceType(device, context, true)); + .superRefine((device, context) => checkDeviceType(device, context, true)) + // `list.get` records carry a virtual device's driver address, and a worker view parses them + // through this schema: it drops the address of a device whose canonical ID is not shown. + .transform((device) => { + if (device.physical || device.driverDeviceId === undefined) return device; + const { driverDeviceId: _address, ...rest } = device; + return rest; + }); /** * The device a `lease.request`/`lease.renew` grant carries -- deliberately narrower than diff --git a/src/core/create-core.ts b/src/core/create-core.ts index f4446112b..bb27c964d 100644 --- a/src/core/create-core.ts +++ b/src/core/create-core.ts @@ -25,6 +25,7 @@ import type { DeviceRequest, Driver, DriverRejection, PrerequisiteCheck } from " import { DriverCatalog, type ModelPreferences } from "./driver-catalog.js"; import { ManagedDeviceLifecycle } from "./managed-device-lifecycle.js"; import { type AcquisitionMaintenance, type LeaseMaintenance, NukeService } from "./nuke-service.js"; +import { PhysicalEnrollment } from "./physical-enrollment.js"; import { QuarantineCoordinator } from "./quarantine-coordinator.js"; import { ReclaimCoordinator } from "./reclaim-coordinator.js"; import type { Registry } from "./registry.js"; @@ -127,6 +128,8 @@ export interface Core { readonly deviceLifecycle: ManagedDeviceLifecycle; readonly doctor: Doctor; readonly drivers: DriverCatalog; + /** `device add` and `device remove`: enrols and unenrols physical devices (ADR 0022 §2). */ + readonly enrollment: PhysicalEnrollment; readonly nuke: NukeService; readonly provisioner: DeviceProvisioner; readonly quarantine: QuarantineCoordinator; @@ -238,6 +241,7 @@ export function createCore(options: CoreOptions): Core { registry, }); const quarantine = new QuarantineCoordinator({ + claims, clock: options.clock, config: options.config.warmPool.quarantine, decisions, @@ -258,6 +262,7 @@ export function createCore(options: CoreOptions): Core { quarantine, registry, }); + const enrollment = new PhysicalEnrollment({ claims, decisions, drivers, registry }); const cleanup = new CleanupExecutor({ eventBus: options.eventBus, lifecycle: deviceLifecycle, @@ -360,6 +365,7 @@ export function createCore(options: CoreOptions): Core { deviceLifecycle, doctor, drivers, + enrollment, nuke, provisioner, quarantine, diff --git a/src/core/device-operation-claims.ts b/src/core/device-operation-claims.ts index cece98da3..c35e631d4 100644 --- a/src/core/device-operation-claims.ts +++ b/src/core/device-operation-claims.ts @@ -6,9 +6,17 @@ * `reclaiming` devices) never mistakes it for one left over from a previous crash, * and so `simlock doctor` never reads a long-but-healthy erase as a stalled * transition. Every release takes one, since none of them wait for the purge. - * See `LeaseReleaseCoordinator#reclaimInBackground`. + * See `LeaseReleaseCoordinator#reclaimInBackground`. `enrollment` is a physical device held from + * `device add`'s second read to its write (ADR 0022 §2). */ -export type DeviceOperation = "boot" | "eviction" | "cleanup" | "nuke" | "recovery" | "reclaim"; +export type DeviceOperation = + | "boot" + | "eviction" + | "cleanup" + | "nuke" + | "recovery" + | "reclaim" + | "enrollment"; /** An exclusive, idempotently releasable claim for one device operation. */ export interface DeviceOperationClaim { diff --git a/src/core/doctor.test.ts b/src/core/doctor.test.ts index 2cc33a42e..d0d44a3ad 100644 --- a/src/core/doctor.test.ts +++ b/src/core/doctor.test.ts @@ -1225,6 +1225,7 @@ describe("Doctor", () => { processes: [], }); const quarantine = new QuarantineCoordinator({ + claims: new DeviceOperationClaims(), clock, config: { maxRetries: 3, @@ -2494,6 +2495,7 @@ describe("Doctor and physical devices (ADR 0022 §4)", () => { const decisions = new SerializedDecision(); const decisionRuns = vi.spyOn(decisions, "run"); const quarantine = new QuarantineCoordinator({ + claims: new DeviceOperationClaims(), clock, config: { maxRetries: 3, diff --git a/src/core/driver.ts b/src/core/driver.ts index b8979755d..f906f2768 100644 --- a/src/core/driver.ts +++ b/src/core/driver.ts @@ -6,6 +6,7 @@ import type { DeviceMode, DeviceSpec, DeviceTransitionUpdate, + PhysicalClass, Platform, VirtualSpec, } from "./domain.js"; @@ -250,6 +251,42 @@ export interface DriverToolVersion { readonly build?: string; } +/** + * Why a driver refuses to enrol a device (ADR 0022 §2): every `DEVICE_NOT_ENROLLABLE` reason + * except `enrolled-on-other-platform`, which only the core can know. `virtual-device` is a + * driver's too, for any virtual device it reads that is not Simlock's own. + */ +export type DriverRefusalReason = + | "not-connected" + | "network-only" + | "untrusted" + | "developer-mode-off" + | "os-too-old" + | "unsupported-kind" + | "locked" + | "unreadable" + | "virtual-device"; + +/** + * What a driver read of a physical device (ADR 0022 §5). `driverDeviceId` is the canonical ID + * whatever ID was asked. `apps` are the app IDs the enrolment app list holds; which apps, per + * platform, is the driver's. A refusal carries the driver's reason and its own message. + */ +export type PhysicalInspection = + | { + readonly enrollable: true; + readonly driverDeviceId: string; + readonly model: string; + readonly osVersion: string; + readonly class: PhysicalClass; + readonly apps: readonly string[]; + } + | { + readonly enrollable: false; + readonly reason: DriverRefusalReason; + readonly message: string; + }; + export interface Driver { readonly platform: Platform; /** @@ -369,6 +406,13 @@ export interface Driver { shutdown(device: VirtualDriverDevice): Promise; destroy(device: VirtualDriverDevice): Promise; listManaged(): Promise; + /** + * Reads a physical device by whatever ID an operator typed, for `device add` (ADR 0022 §5). A + * read: it changes nothing on the device. A device it cannot enrol is a refusal with the + * driver's reason and message, and so is a read that failed or timed out (`unreadable`, naming + * the read): the core relays it and a throw is a driver bug. + */ + inspectPhysical(id: string): Promise; /** Read-only: must never trigger a runtime / system-image download. */ listCatalog(): Promise; estimate(estimate: DriverEstimate, spec: DeviceSpec): number; diff --git a/src/core/fake-driver.ts b/src/core/fake-driver.ts index 4a9e072df..333810b1f 100644 --- a/src/core/fake-driver.ts +++ b/src/core/fake-driver.ts @@ -1,5 +1,12 @@ import type { Clock } from "../ports/index.js"; -import type { DeviceClass, DeviceMode, DeviceSpec, Platform, VirtualSpec } from "./domain.js"; +import type { + DeviceClass, + DeviceMode, + DeviceSpec, + PhysicalClass, + Platform, + VirtualSpec, +} from "./domain.js"; import { type ComponentInstallProgress, type ComponentInstallResult, @@ -13,12 +20,14 @@ import { type DriverDevice, type DriverEstimate, type DriverReality, + type DriverRefusalReason, type InstalledComponent, type LegacyDevice, type ObservedRunState, type PassthroughCommand, PassthroughRefusedError, type PassthroughContext, + type PhysicalInspection, removeListedComponent, RuntimeMissingError, UnknownModelError, @@ -34,6 +43,8 @@ export type FakeDriverOperation = | "shutdown" | "destroy" | "listManaged" + /** Logged with the ID the operator typed. */ + | "inspectPhysical" | "listCatalog" | "listComponents" | "removeComponent" @@ -44,6 +55,19 @@ export type FakeDriverOperation = export type DriverEstimateOperation = "provision" | "boot" | "reclaim"; +/** + * One physical device the fake can read. `ids` lists every ID it answers to: the first is its + * canonical ID, the rest are aliases. A `refusal` refuses every one of them. + */ +export interface FakePhysicalDevice { + readonly ids: readonly string[]; + readonly model?: string; + readonly osVersion?: string; + readonly class?: PhysicalClass; + readonly apps?: readonly string[]; + readonly refusal?: { readonly reason: DriverRefusalReason; readonly message: string }; +} + export interface FakeDriverCall { readonly arguments: readonly unknown[]; readonly operation: FakeDriverOperation; @@ -128,6 +152,8 @@ export interface FakeDriverOptions { readonly legacyDevices?: Readonly>; readonly reclaimResult?: "ready" | "shutdown"; readonly reclaimStrategy?: "erase" | "snapshot" | "wipe"; + /** The physical devices `inspectPhysical` reads; an ID none lists is `not-connected`. */ + readonly physicalDevices?: readonly FakePhysicalDevice[]; } export class FakeDriverUnknownDeviceError extends Error { @@ -187,6 +213,7 @@ export class FakeDriver implements Driver { /** Bumped on every `makeReady` boot -- mirrors a real driver reassigning a port per boot. */ readonly #bootCounts = new Map(); #managedReality: DriverReality | undefined; + #physicalDevices: readonly FakePhysicalDevice[]; constructor(options: FakeDriverOptions) { this.#availableOsVersions = new Set(options.availableOsVersions ?? ["latest"]); @@ -212,6 +239,7 @@ export class FakeDriver implements Driver { this.deviceRoot = options.deviceRoot ?? `/fake/${options.platform}`; this.#reclaimResult = options.reclaimResult ?? "ready"; this.#reclaimStrategy = options.reclaimStrategy ?? "wipe"; + this.#physicalDevices = options.physicalDevices ?? []; this.#install = options; this.#receipts = preinstalledReceipts(this.#availableOsVersions); } @@ -518,6 +546,29 @@ export class FakeDriver implements Driver { }; } + /** Replaces the scripted physical devices; the next `inspectPhysical` reads the new ones. */ + setPhysicalDevices(devices: readonly FakePhysicalDevice[]): void { + this.#physicalDevices = devices; + } + + async inspectPhysical(id: string): Promise { + await this.#beforeCall("inspectPhysical", id); + const device = this.#physicalDevices.find((candidate) => candidate.ids.includes(id)); + if (device === undefined) { + return { enrollable: false, message: `${id} is not connected`, reason: "not-connected" }; + } + if (device.refusal !== undefined) return { enrollable: false, ...device.refusal }; + const [canonical] = device.ids; + return { + apps: [...(device.apps ?? [])], + class: device.class ?? "phone", + driverDeviceId: canonical ?? id, + enrollable: true, + model: device.model ?? "Fake Phone", + osVersion: device.osVersion ?? "1.0", + }; + } + async listCatalog(): Promise { await this.#beforeCall("listCatalog"); const runtimes = [...this.#availableOsVersions].sort(compareVersions); diff --git a/src/core/index.ts b/src/core/index.ts index e2bdd960a..8123533a1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -58,6 +58,7 @@ export { type DriverDevice, type DriverEstimate, type DriverReality, + type DriverRefusalReason, type DriverRejection, type DriverToolVersion, InsufficientDiskSpaceError, @@ -67,6 +68,7 @@ export { type ObservedDevice, type ObservedRunState, type PassthroughCommand, + type PhysicalInspection, type PrerequisiteCheck, PassthroughRefusedError, removeListedComponent, @@ -121,6 +123,14 @@ export { } from "./driver.js"; export { type Core, type CorePorts, createCore } from "./create-core.js"; export { DeviceOperationClaims, type DeviceOperationClaim } from "./device-operation-claims.js"; +export { + DeviceBusyError, + DeviceNotEnrollableError, + PhysicalEnrollment, + PhysicalInspectionError, + UnknownEnrolledDeviceError, + type PhysicalEnrollmentResult, +} from "./physical-enrollment.js"; export { DeviceProvisioner } from "./device-provisioner.js"; export { exactRequirement, diff --git a/src/core/physical-enrollment.ts b/src/core/physical-enrollment.ts new file mode 100644 index 000000000..c08e4e025 --- /dev/null +++ b/src/core/physical-enrollment.ts @@ -0,0 +1,201 @@ +import type { DeviceOperationClaims } from "./device-operation-claims.js"; +import type { PhysicalDevice, Platform } from "./domain.js"; +import type { Driver, DriverRefusalReason } from "./driver.js"; +import type { PhysicalEnrollmentInput, Registry } from "./registry.js"; +import type { SerializedDecision } from "./serialized-decision.js"; + +/** Every driver reason, and the one only the core can know. */ +export type DeviceNotEnrollableReason = DriverRefusalReason | "enrolled-on-other-platform"; + +/** A device `device.add` will not enrol (ADR 0022 §2): the reason, in the driver's own words. */ +export class DeviceNotEnrollableError extends Error { + constructor( + readonly platform: Platform, + /** The ID as the operator typed it. */ + readonly id: string, + readonly reason: DeviceNotEnrollableReason, + message: string, + /** Set when the core knows the device's canonical ID. */ + readonly canonicalId?: string, + ) { + super(message); + this.name = "DeviceNotEnrollableError"; + } +} + +/** The device is leased, being reclaimed or held by another enrollment. */ +export class DeviceBusyError extends Error { + constructor( + readonly id: string, + readonly reason: "leased" | "reclaiming" | "busy", + ) { + super( + reason === "leased" + ? `${id} is leased: release it first` + : reason === "reclaiming" + ? `${id} is being reclaimed: try again when it finishes` + : `${id} is being enrolled or removed: try again in a moment`, + ); + this.name = "DeviceBusyError"; + } +} + +/** `device.remove` of an ID with no enrolled device. */ +export class UnknownEnrolledDeviceError extends Error { + constructor(readonly id: string) { + super(`${id} is not an enrolled device`); + this.name = "UnknownEnrolledDeviceError"; + } +} + +/** A driver's `inspectPhysical` threw, or named another device than the one asked for. */ +export class PhysicalInspectionError extends Error { + constructor(message: string) { + super(message); + this.name = "PhysicalInspectionError"; + } +} + +export interface PhysicalEnrollmentResult { + readonly device: PhysicalDevice; + readonly reenrolled: boolean; +} + +export interface PhysicalEnrollmentOptions { + readonly claims: Pick; + readonly decisions: Pick; + readonly drivers: { get(platform: Platform): Driver }; + readonly registry: Pick< + Registry, + "enrollPhysical" | "reenrollPhysical" | "snapshot" | "unenrollPhysical" + >; +} + +/** + * Enrols and unenrols physical devices (ADR 0022 §2). The driver reads a device and decides every + * refusal; this class only relays it, finds the one record a canonical ID has, and keeps it + * from changing under a lease, a reclaim or another enrollment. Never writes to a device. + */ +export class PhysicalEnrollment { + constructor(private readonly options: PhysicalEnrollmentOptions) {} + + async add(platform: Platform, id: string): Promise { + this.#refuseVirtual(platform, id); + const driver = this.options.drivers.get(platform); + const read = await this.#inspect(driver, platform, id); + + const claimed = await this.options.decisions.run(async () => { + const existing = this.#enrolled(read.driverDeviceId); + if (existing === undefined) { + return { created: await this.options.registry.enrollPhysical({ ...read, platform }) }; + } + if (existing.spec.platform !== platform) { + throw new DeviceNotEnrollableError( + platform, + id, + "enrolled-on-other-platform", + `${id} is enrolled as a ${existing.spec.platform} device`, + read.driverDeviceId, + ); + } + this.#requireFree(existing); + const claim = this.options.claims.tryClaim(existing.id, "enrollment"); + if (claim === undefined) throw new DeviceBusyError(existing.driverDeviceId, "busy"); + return { claim, existing }; + }); + if ("created" in claimed) { + return { device: claimed.created, reenrolled: false }; + } + + const { claim, existing } = claimed; + try { + const again = await this.#inspect(driver, platform, id, existing.driverDeviceId); + if (again.driverDeviceId !== existing.driverDeviceId) { + throw new PhysicalInspectionError( + `${existing.driverDeviceId} was read again as ${again.driverDeviceId}`, + ); + } + const device = await this.options.decisions.run(() => + this.options.registry.reenrollPhysical(existing.id, { ...again, platform }), + ); + return { device, reenrolled: true }; + } finally { + claim.release(); + } + } + + async remove(id: string): Promise { + return this.options.decisions.run(() => { + const existing = this.#enrolled(id); + if (existing === undefined) throw new UnknownEnrolledDeviceError(id); + this.#requireFree(existing); + return this.options.registry.unenrollPhysical(existing.id); + }); + } + + /** Simlock's own simulators and emulators are never enrolled, by either of their names. */ + #refuseVirtual(platform: Platform, id: string): void { + const own = this.options.registry.snapshot.virtualDevices.find( + (device) => + device.state !== "deleted" && (device.driverDeviceId === id || device.address === id), + ); + if (own !== undefined) { + throw new DeviceNotEnrollableError( + platform, + id, + "virtual-device", + `${id} is a simulator or emulator Simlock manages`, + ); + } + } + + async #inspect( + driver: Driver, + platform: Platform, + id: string, + canonicalId?: string, + ): Promise> { + let inspection: Awaited>; + try { + inspection = await driver.inspectPhysical(id); + } catch (error: unknown) { + throw new PhysicalInspectionError( + `Reading ${id} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!inspection.enrollable) { + throw new DeviceNotEnrollableError( + platform, + id, + inspection.reason, + inspection.message, + canonicalId, + ); + } + return { + apps: inspection.apps, + class: inspection.class, + driverDeviceId: inspection.driverDeviceId, + model: inspection.model, + osVersion: inspection.osVersion, + }; + } + + /** The one non-deleted physical record a canonical ID has, if any. */ + #enrolled(canonicalId: string): PhysicalDevice | undefined { + return this.options.registry.snapshot.devices.find( + (device): device is PhysicalDevice => + device.physical && device.state !== "deleted" && device.driverDeviceId === canonicalId, + ); + } + + /** The one busy check `add` and `remove` share: what holds a device, by name. */ + #requireFree(device: PhysicalDevice): void { + if (device.state === "leased") throw new DeviceBusyError(device.driverDeviceId, "leased"); + const held = this.options.claims.claim(device.id); + if (device.state === "reclaiming" || held?.kind === "reclaim") { + throw new DeviceBusyError(device.driverDeviceId, "reclaiming"); + } + if (held !== undefined) throw new DeviceBusyError(device.driverDeviceId, "busy"); + } +} diff --git a/src/core/quarantine-coordinator.test.ts b/src/core/quarantine-coordinator.test.ts index 940f48cfc..e04767cdd 100644 --- a/src/core/quarantine-coordinator.test.ts +++ b/src/core/quarantine-coordinator.test.ts @@ -8,6 +8,7 @@ import { MemoryFilesystem, MemoryLogSink, } from "../ports/index.js"; +import { DeviceOperationClaims } from "./device-operation-claims.js"; import { DriverCatalog } from "./driver-catalog.js"; import type { VirtualDevice, VirtualSpec } from "./domain.js"; import { FakeDriver } from "./fake-driver.js"; @@ -144,7 +145,9 @@ async function createHarness( const target = { ...device("dev_1", driverDevice.deviceId), ...options.target }; const registry = new FakeRegistry([target]); const notifyAvailability = vi.fn(); + const claims = new DeviceOperationClaims(); const coordinator = new QuarantineCoordinator({ + claims, clock, config: options.config ?? retryConfig, decisions: new SerializedDecision(), @@ -154,7 +157,7 @@ async function createHarness( notifyAvailability, registry, }); - return { bus, clock, coordinator, driver, notifyAvailability, registry, target }; + return { bus, claims, clock, coordinator, driver, notifyAvailability, registry, target }; } function eventsOf(bus: EventBus): readonly EventEnvelope[] { @@ -549,6 +552,7 @@ describe("QuarantineCoordinator", () => { const notifyAvailability = vi.fn(); const bus = new EventBus(clock); const coordinator = new QuarantineCoordinator({ + claims: new DeviceOperationClaims(), clock, config: retryConfig, decisions: new SerializedDecision(), @@ -578,6 +582,7 @@ describe("QuarantineCoordinator", () => { }, ]); const coordinator = new QuarantineCoordinator({ + claims: new DeviceOperationClaims(), clock, config: retryConfig, decisions: new SerializedDecision(), @@ -603,6 +608,7 @@ describe("QuarantineCoordinator", () => { { ...device("dev_ready", driverDevice.deviceId), state: "ready", quarantineNextRetryAt: 500 }, ]); const coordinator = new QuarantineCoordinator({ + claims: new DeviceOperationClaims(), clock, config: retryConfig, decisions: new SerializedDecision(), @@ -669,6 +675,7 @@ describe("QuarantineCoordinator for a physical device (ADR 0022 §4)", () => { const decisions = new SerializedDecision(); const decisionRuns = vi.spyOn(decisions, "run"); const coordinator = new QuarantineCoordinator({ + claims: new DeviceOperationClaims(), clock, config: retryConfig, decisions, diff --git a/src/core/quarantine-coordinator.ts b/src/core/quarantine-coordinator.ts index a26ad24f4..2d2c247a5 100644 --- a/src/core/quarantine-coordinator.ts +++ b/src/core/quarantine-coordinator.ts @@ -1,5 +1,6 @@ import type { EventBus } from "../bus/index.js"; import { type Clock, type Logger, NoopLogger, type TimerHandle } from "../ports/index.js"; +import type { DeviceOperationClaim, DeviceOperationClaims } from "./device-operation-claims.js"; import { type DeviceRecord, mayBeGranted, type Platform, type VirtualDevice } from "./domain.js"; import type { Driver, VirtualDriverDevice } from "./driver.js"; import type { SerializedDecision } from "./serialized-decision.js"; @@ -40,6 +41,8 @@ export interface QuarantineRetryConfig { } export interface QuarantineCoordinatorOptions { + /** A retry holds the device's `reclaim` claim from its first driver call to its last write. */ + readonly claims: Pick; readonly clock: Clock; readonly config: QuarantineRetryConfig; readonly decisions: Pick; @@ -199,10 +202,30 @@ export class QuarantineCoordinator { ); if (device === undefined) return; + // Whoever claims first wins: a claim already held (an enrollment, a reclaim) makes the retry + // wait one backoff, with its attempt count unchanged, and look again. + const claim = this.options.claims.tryClaim(deviceId, "reclaim"); + if (claim === undefined) { + this.#arm(deviceId, this.options.clock.now() + this.options.config.retryBackoffMs); + return; + } + try { + await this.#retryClaimed(device, claim); + } finally { + claim.release(); + } + } + + /** + * Every exit releases the claim right after the last write, before the facts and the wake-up + * that follow it: the planner never grants a claimed device, so a wake-up under the claim + * would pass over the device it was sent for (the order `LeaseReleaseCoordinator` uses). + */ + async #retryClaimed(device: VirtualDevice, claim: DeviceOperationClaim): Promise { const attempts = (device.quarantineAttempts ?? 0) + 1; const driver = this.options.drivers.get(device.spec.platform); if (!mayBeGranted(device)) { - await this.#retryDelete(driver, device, attempts); + await this.#retryDelete(driver, device, attempts, claim); return; } let result: Awaited>; @@ -210,16 +233,17 @@ export class QuarantineCoordinator { result = await driver.reclaim(toDriverDevice(device), { clean: "standard" }); } catch (error: unknown) { this.#logRetryFailure("quarantine reclaim retry failed", device, "reclaim", attempts, error); - await this.#retryFailed(device, attempts); + await this.#retryFailed(device, attempts, claim); return; } await this.options.decisions.run(() => - this.options.registry.recoverFromQuarantine(deviceId, result.state), + this.options.registry.recoverFromQuarantine(device.id, result.state), ); + claim.release(); this.options.eventBus.emit( "device.quarantine-recovered", - { attempts, deviceId, strategy: result.strategy }, + { attempts, deviceId: device.id, strategy: result.strategy }, "quarantine-coordinator", ); this.options.notifyAvailability(); @@ -230,17 +254,23 @@ export class QuarantineCoordinator { * back in the pool for a second one. It retries the delete instead, and success is * `device.deleted`, not `device.quarantine-recovered`: nothing rejoined the pool. */ - async #retryDelete(driver: Driver, device: VirtualDevice, attempts: number): Promise { + async #retryDelete( + driver: Driver, + device: VirtualDevice, + attempts: number, + claim: DeviceOperationClaim, + ): Promise { try { await driver.destroy(toDriverDevice(device)); } catch (error: unknown) { this.#logRetryFailure("quarantine delete retry failed", device, "delete", attempts, error); - await this.#retryFailed(device, attempts); + await this.#retryFailed(device, attempts, claim); return; } await this.options.decisions.run(() => this.options.registry.deleteQuarantined(device.id, "lease-end"), ); + claim.release(); this.options.notifyAvailability(); } @@ -254,19 +284,28 @@ export class QuarantineCoordinator { this.#logger.warn(message, { deviceId: device.id, step, attempts, error: stableError(error) }); } - async #retryFailed(device: VirtualDevice, attempts: number): Promise { + async #retryFailed( + device: VirtualDevice, + attempts: number, + claim: DeviceOperationClaim, + ): Promise { if (attempts >= this.options.config.maxRetries) { - await this.#giveUp(device, attempts); + await this.#giveUp(device, attempts, claim); return; } const nextRetryAt = this.options.clock.now() + backoffDelay(this.options.config, attempts); await this.options.decisions.run(() => this.options.registry.recordQuarantineRetryFailure(device.id, attempts, nextRetryAt), ); + claim.release(); this.#arm(device.id, nextRetryAt); } - async #giveUp(device: VirtualDevice, attempts: number): Promise { + async #giveUp( + device: VirtualDevice, + attempts: number, + claim: DeviceOperationClaim, + ): Promise { const driver = this.options.drivers.get(device.spec.platform); try { await driver.destroy(toDriverDevice(device)); @@ -280,6 +319,7 @@ export class QuarantineCoordinator { await this.options.decisions.run(() => this.options.registry.strandQuarantine(device.id, attempts), ); + claim.release(); this.options.eventBus.emit( "device.quarantine-stranded", { attempts, deviceId: device.id, error: stableError(error) }, @@ -290,6 +330,7 @@ export class QuarantineCoordinator { await this.options.decisions.run(() => this.options.registry.deleteQuarantined(device.id, "quarantine-coordinator"), ); + claim.release(); this.options.eventBus.emit( "device.quarantine-abandoned", { attempts, deviceId: device.id }, diff --git a/src/core/reclaim-coordinator.test.ts b/src/core/reclaim-coordinator.test.ts index 65fdd3428..6f59c9ece 100644 --- a/src/core/reclaim-coordinator.test.ts +++ b/src/core/reclaim-coordinator.test.ts @@ -57,7 +57,7 @@ class TestRegistry { if (event.event === "device.reclaimed") { this.eventBus.emit(event.event, event.payload, "test-registry"); } else { - this.eventBus.emit(event.event, event.payload, "test-registry"); + this.eventBus.emit(event.event, { ...event.payload, physical: false }, "test-registry"); } return updated; } diff --git a/src/core/registry.ts b/src/core/registry.ts index 26d7a9a90..f9e215906 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -105,6 +105,16 @@ export interface RegisterDeviceInput { readonly provisionDuration: number; } +/** What a driver's read of a physical device gives a record: everything `device.add` writes. */ +export interface PhysicalEnrollmentInput { + readonly platform: Platform; + readonly driverDeviceId: string; + readonly model: string; + readonly osVersion: string; + readonly class: PhysicalClass; + readonly apps: readonly string[]; +} + export interface ReleasedLease { readonly device: DeviceRecord; readonly lease: LeaseRecord; @@ -138,7 +148,11 @@ export type RegistryDeviceEvent = | { readonly event: "device.ready"; readonly payload: EventMap["device.ready"] } | { readonly event: "device.reclaimed"; readonly payload: EventMap["device.reclaimed"] } | { readonly event: "device.shutdown"; readonly payload: EventMap["device.shutdown"] } - | { readonly event: "device.deleted"; readonly payload: EventMap["device.deleted"] }; + | { + readonly event: "device.deleted"; + /** `physical` is the record's own: the registry adds it when it emits. */ + readonly payload: Omit; + }; class RegistryLoadError extends Error { constructor(message: string) { @@ -302,7 +316,7 @@ export class Registry implements LeaseRequestStore { await this.#commit(devices, this.#leases); if (event !== undefined) { - this.#emitDeviceEvent(event); + this.#emitDeviceEvent(event, updated); } return cloneDevice(updated); @@ -455,7 +469,11 @@ export class Registry implements LeaseRequestStore { const devices = [...this.#devices]; devices[index] = updated as DeviceRecord; await this.#commit(devices, this.#leases); - this.options.eventBus.emit("device.deleted", { deviceId, initiator }, "registry"); + this.options.eventBus.emit( + "device.deleted", + { deviceId, initiator, physical: device.physical }, + "registry", + ); return cloneDevice(updated as DeviceRecord); } @@ -545,7 +563,11 @@ export class Registry implements LeaseRequestStore { return cloneDevice(device); } const deleted = await this.#commitMissing(device, this.#leases); - this.options.eventBus.emit("device.deleted", { deviceId, initiator }, "registry"); + this.options.eventBus.emit( + "device.deleted", + { deviceId, initiator, physical: device.physical }, + "registry", + ); return cloneDevice(deleted); } @@ -575,7 +597,11 @@ export class Registry implements LeaseRequestStore { ); const ended = { device: cloneDevice(deleted), lease: cloneLease(lease) }; announceLeaseEnd(ended); - this.options.eventBus.emit("device.deleted", { deviceId: device.id, initiator }, "registry"); + this.options.eventBus.emit( + "device.deleted", + { deviceId: device.id, initiator, physical: device.physical }, + "registry", + ); return ended; } @@ -602,6 +628,118 @@ export class Registry implements LeaseRequestStore { return deleted; } + /** + * Writes a new physical device `ready` (ADR 0022 §2): the first enrollment of a canonical ID. + * `device.enrolled` follows the commit. + */ + async enrollPhysical(input: PhysicalEnrollmentInput): Promise { + const now = this.options.clock.now(); + const record: PhysicalDevice = { + createdAt: now, + driverData: {}, + driverDeviceId: input.driverDeviceId, + enrolledApps: [...input.apps], + id: `dev_${this.options.idGenerator.generate()}`, + physical: true, + readyAt: now, + spec: { + class: input.class, + model: input.model, + osVersion: input.osVersion, + physical: true, + platform: input.platform, + }, + state: "ready", + }; + await this.#commit([...this.#devices, record], this.#leases); + this.#emitEnrolled(record, false); + return cloneDevice(record) as PhysicalDevice; + } + + /** + * Rewrites an enrolled physical device from a fresh read (ADR 0022 §2): model, OS version, + * class and app list replaced, `ready`, the quarantine fields cleared, the registry ID kept. + * Only a `ready` or `quarantined` record is rewritten. + */ + async reenrollPhysical( + deviceId: string, + input: PhysicalEnrollmentInput, + ): Promise { + const { device, index } = this.#requireDeviceRecord(deviceId); + if (!device.physical || (device.state !== "ready" && device.state !== "quarantined")) { + throw new RegistryEventError(`Cannot re-enrol device: ${deviceId}`); + } + const { + quarantineAttempts: _quarantineAttempts, + quarantineNextRetryAt: _quarantineNextRetryAt, + quarantinedAt: _quarantinedAt, + deferredReclaimLeaseId: _deferred, + ...kept + } = device; + const updated: PhysicalDevice = { + ...kept, + enrolledApps: [...input.apps], + readyAt: this.options.clock.now(), + spec: { + class: input.class, + model: input.model, + osVersion: input.osVersion, + physical: true, + platform: device.spec.platform, + }, + state: "ready", + }; + const devices = [...this.#devices]; + devices[index] = updated; + await this.#commit(devices, this.#leases); + this.#emitEnrolled(updated, true); + return cloneDevice(updated) as PhysicalDevice; + } + + /** + * Writes an enrolled physical device `deleted` (ADR 0022 §2): it is forgotten, and nothing is + * done to the device. `device.deleted` follows the commit with `initiator: "operator"`. + */ + async unenrollPhysical(deviceId: string): Promise { + const { device, index } = this.#requireDeviceRecord(deviceId); + if (!device.physical || (device.state !== "ready" && device.state !== "quarantined")) { + throw new RegistryEventError(`Cannot unenrol device: ${deviceId}`); + } + const { + quarantineAttempts: _quarantineAttempts, + quarantineNextRetryAt: _quarantineNextRetryAt, + quarantinedAt: _quarantinedAt, + ...kept + } = device; + const updated = transition(kept, "deleted"); + const devices = [...this.#devices]; + devices[index] = updated; + await this.#commit(devices, this.#leases); + this.options.eventBus.emit( + "device.deleted", + { deviceId, initiator: "operator", physical: true }, + "registry", + ); + return cloneDevice(updated) as PhysicalDevice; + } + + #emitEnrolled(device: PhysicalDevice, reenrolled: boolean): void { + this.options.eventBus.emit( + "device.enrolled", + { + appCount: device.enrolledApps.length, + class: device.spec.class, + deviceId: device.id, + driverDeviceId: device.driverDeviceId, + model: device.spec.model, + osVersion: device.spec.osVersion, + platform: device.spec.platform, + reenrolled, + }, + "registry", + ); + } + async createLease({ deviceId, ownerId, @@ -905,7 +1043,7 @@ export class Registry implements LeaseRequestStore { return records; } - #emitDeviceEvent(event: RegistryDeviceEvent): void { + #emitDeviceEvent(event: RegistryDeviceEvent, device: DeviceRecord): void { switch (event.event) { case "device.ready": this.options.eventBus.emit(event.event, event.payload, "registry"); @@ -917,7 +1055,11 @@ export class Registry implements LeaseRequestStore { this.options.eventBus.emit(event.event, event.payload, "registry"); return; case "device.deleted": - this.options.eventBus.emit(event.event, event.payload, "registry"); + this.options.eventBus.emit( + event.event, + { ...event.payload, physical: device.physical }, + "registry", + ); } } } diff --git a/src/core/warm-pool/converger.test.ts b/src/core/warm-pool/converger.test.ts index 6b55d2607..ddf6fdd7e 100644 --- a/src/core/warm-pool/converger.test.ts +++ b/src/core/warm-pool/converger.test.ts @@ -478,7 +478,11 @@ describe("warm pool converger", () => { await vi.waitFor(() => expect(rig.shutdownCalls).toEqual(["a"])); expect(rig.waitingDemand).toHaveBeenCalledTimes(1); rig.eventBus.emit("device.shutdown", { deviceId: "x", initiator: "test" }, "test"); - rig.eventBus.emit("device.deleted", { deviceId: "y", initiator: "test" }, "test"); + rig.eventBus.emit( + "device.deleted", + { deviceId: "y", initiator: "test", physical: false }, + "test", + ); release(); await rig.pool.settle(); @@ -764,7 +768,7 @@ describe("warm pool converger", () => { ], ["lease.released", { deviceId: "d", leaseId: "l", ownerId: "o", reason: "explicit" }], ["device.shutdown", { deviceId: "d", initiator: "test" }], - ["device.deleted", { deviceId: "d", initiator: "test" }], + ["device.deleted", { deviceId: "d", initiator: "test", physical: false }], ["cleanup.executed", { action: "shutdown", reason: "r", ruleName: "n", target: "d" }], ] as const; const rig = harness([]); diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index 60b1a7afd..cd9941f7e 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -10,14 +10,18 @@ import { type Config, type DeviceRecord, type DeviceRequest, + DeviceBusyError, + DeviceNotEnrollableError, type Doctor, type Driver, type HostFacts, isStalledTransition, type Nuke, type PhysicalClass, + type PhysicalEnrollment, type Registry, type StallInput, + UnknownEnrolledDeviceError, effectiveAllowDownload, RuntimeMissingError, transitionEnteredAt, @@ -118,6 +122,8 @@ export interface DispatcherOptions { * Injected because `error-code.ts` imports this module. Unset or `undefined`: `INTERNAL`. */ readonly errorCode?: (error: unknown) => string | undefined; readonly nuke?: Nuke; + /** Answers `device.add` and `device.remove`; optional for the tests that never enrol one. */ + readonly enrollment?: Pick; /** * Builds the scoped command behind `simlock simctl` / `simlock adb` (ADR 0001, decision 7). * Optional for the same reason as `nuke`/`doctor`: the many tests that never issue a @@ -276,6 +282,8 @@ export class Dispatcher { "component.install": this.#componentInstall, "component.list": this.#componentList, "component.remove": this.#componentRemove, + "device.add": this.#deviceAdd, + "device.remove": this.#deviceRemove, "worker.list": this.#workerList, "worker.drain": unsupportedOnWorker("worker.drain"), "worker.undrain": unsupportedOnWorker("worker.undrain"), @@ -813,6 +821,45 @@ export class Dispatcher { } }; + /** + * ADR 0022 §2: enrols the physical device the driver reads for `id`. A refusal and a busy device + * carry their reason as details, so a transport can show them; every other failure is + * classified as it is thrown. + */ + #deviceAdd: Handler<"device.add"> = async (input) => { + try { + const { device, reenrolled } = await this.#requireEnrollment().add(input.platform, input.id); + return { + appCount: device.enrolledApps.length, + class: device.spec.class, + driverDeviceId: device.driverDeviceId, + id: device.id, + model: device.spec.model, + osVersion: device.spec.osVersion, + platform: device.spec.platform, + reenrolled, + }; + } catch (error: unknown) { + throw describeEnrollmentError(error); + } + }; + + #deviceRemove: Handler<"device.remove"> = async (input) => { + try { + const device = await this.#requireEnrollment().remove(input.id); + return { driverDeviceId: device.driverDeviceId, id: device.driverDeviceId }; + } catch (error: unknown) { + throw describeEnrollmentError(error); + } + }; + + #requireEnrollment(): Pick { + if (this.options.enrollment === undefined) { + throw new DispatchError("INTERNAL", "Device enrollment is unavailable"); + } + return this.options.enrollment; + } + /** * ADR §11: the daemon is the only owner of `tokens.json` -- `TokenStore.create` never * persists the plaintext `secret`, only its hash, exactly as it did when the CLI called it @@ -900,6 +947,28 @@ function killQuietly(handle: StreamingProcessHandle, signal: NodeJS.Signals): vo } } +/** The refusals of `device.add` and `device.remove` as the dispatcher's own, with their details. */ +function describeEnrollmentError(error: unknown): unknown { + if (error instanceof DeviceNotEnrollableError) { + return new DispatchError("DEVICE_NOT_ENROLLABLE", error.message, { + id: error.id, + platform: error.platform, + reason: error.reason, + ...(error.canonicalId === undefined ? {} : { canonicalId: error.canonicalId }), + }); + } + if (error instanceof DeviceBusyError) { + return new DispatchError("DEVICE_BUSY", error.message, { + id: error.id, + reason: error.reason, + }); + } + if (error instanceof UnknownEnrolledDeviceError) { + return new DispatchError("UNKNOWN_DEVICE", error.message, { id: error.id }); + } + return error; +} + /** ADR 0012 §2: an operation that acts on a gateway's workers, asked of a worker. */ function unsupportedOnWorker( operation: OperationName, diff --git a/src/daemon/main.ts b/src/daemon/main.ts index f98ecde01..d8d049d51 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -436,6 +436,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise { + return { + enrollable: false, + message: "this driver cannot read physical devices yet", + reason: "not-connected", + }; + } + async listManaged(): Promise { const avdNames = await this.#listAvdNames(); const { settledSerials, unattributableTransitionalSerial } = await this.#scanAdbSerials(); diff --git a/src/drivers/ios/index.ts b/src/drivers/ios/index.ts index 96b279d94..f98d3b5be 100644 --- a/src/drivers/ios/index.ts +++ b/src/drivers/ios/index.ts @@ -17,6 +17,7 @@ import { DriverCrashError, type DriverEstimate, type DriverReality, + type PhysicalInspection, ensureOwnedRoot, type EnsureOwnedRootOptions, type InstalledComponent, @@ -1281,6 +1282,15 @@ export class IosSimctlDriver implements Driver { await this.#legacySimctl(["delete", udid], COMMAND_TIMEOUT_MS); } + // fallow-ignore-next-line unused-class-member -- called through the core's Driver interface. + async inspectPhysical(_id: string): Promise { + return { + enrollable: false, + message: "this driver cannot read physical devices yet", + reason: "not-connected", + }; + } + async listManaged(): Promise { const result = await this.#simctl(["list", "-j", "devices"], COMMAND_TIMEOUT_MS); const parsed = parseManagedDevices(JSON.parse(result.stdout) as unknown); diff --git a/src/gateway/dispatcher.ts b/src/gateway/dispatcher.ts index 06a5e161e..e4bc26ede 100644 --- a/src/gateway/dispatcher.ts +++ b/src/gateway/dispatcher.ts @@ -195,6 +195,15 @@ export class GatewayDispatcher { "component.remove", "component.remove removes a component from one machine; run it against a worker", ), + // ADR 0022 §2: a device is plugged into one machine, and a gateway asks no worker. + "device.add": unsupportedByDesign( + "device.add", + "device.add enrols a device plugged into one machine; run it on the worker the device is plugged into", + ), + "device.remove": unsupportedByDesign( + "device.remove", + "device.remove unenrols a device plugged into one machine; run it on the worker the device is plugged into", + ), "lease.request": this.#leaseRequest, "lease.renew": this.#leaseRenew, diff --git a/src/http/app.ts b/src/http/app.ts index 7cec56ae6..758596560 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -152,6 +152,9 @@ const componentInstallBodySchema = z }) .strict(); +/** Shape only: the platform and the ID's bounds are `device.add`'s own contract schema's answer. */ +const deviceAddBodySchema = z.object({ id: z.string(), platform: z.string() }).strict(); + /** A route's JSON body, validated against its schema; a body that fails is `400 BAD_REQUEST`. */ function jsonBody(schema: Schema) { return zValidator("json", schema, (result, c) => { @@ -303,6 +306,26 @@ export function createHttpApp(deps: HttpGatewayDeps): Hono & HttpAppDisposa return c.json(result); }); + // ADR 0022 §2: an admin enrols and unenrols a device plugged into this host. The role check, + // the bounds on `id` and what a refusal says are the operations' own, through the dispatcher. + app.post("/v1/devices", agentAuth, jsonBody(deviceAddBodySchema), async (c) => { + const result = await deps.dispatch( + "device.add", + c.req.valid("json"), + buildHttpSession(c.get("identity")), + ); + return c.json(result); + }); + + app.delete("/v1/devices/:id", agentAuth, async (c) => { + const result = await deps.dispatch( + "device.remove", + { id: c.req.param("id") }, + buildHttpSession(c.get("identity")), + ); + return c.json(result); + }); + app.post("/v1/lease-requests", agentAuth, jsonBody(leaseRequestBodySchema), async (c) => { const identity = c.get("identity"); const body = c.req.valid("json"); diff --git a/src/leasing/create-leasing.test.ts b/src/leasing/create-leasing.test.ts index f9cbb0960..ca2aff5c6 100644 --- a/src/leasing/create-leasing.test.ts +++ b/src/leasing/create-leasing.test.ts @@ -3004,7 +3004,11 @@ describe("createLeasing warm pool", () => { await seedReady(harness); await seedReady(harness); - harness.bus.emit("device.deleted", { deviceId: "x", initiator: "test" }, "test"); + harness.bus.emit( + "device.deleted", + { deviceId: "x", initiator: "test", physical: false }, + "test", + ); await harness.engine.settle(); expect(harness.registry.snapshot.devices.map((item) => item.state)).toEqual(["ready", "ready"]); diff --git a/src/simlock-client/client.ts b/src/simlock-client/client.ts index 3001d961b..f903d2825 100644 --- a/src/simlock-client/client.ts +++ b/src/simlock-client/client.ts @@ -38,7 +38,11 @@ import type { ComponentRemoveInput, ComponentRemoveOutput, DaemonStopOutput, + DeviceAddInput, + DeviceAddOutput, DeviceRecoveredPush, + DeviceRemoveInput, + DeviceRemoveOutput, DeviceUnhealthyPush, ExecInput, ExecOutput, @@ -97,7 +101,11 @@ export type { ComponentRemoveInput, ComponentRemoveOutput, DaemonStopOutput, + DeviceAddInput, + DeviceAddOutput, DeviceRecoveredPush, + DeviceRemoveInput, + DeviceRemoveOutput, DeviceUnhealthyPush, ExecInput, ExecOutput, @@ -277,6 +285,22 @@ export interface SimlockAdminClient extends SimlockClient { * counts as details) or `COMPONENT_BUSY`. */ removeComponent(input: ComponentRemoveInput): Promise; + + /** + * ADR 0022 §2: enrols the physical device plugged into the host this daemon runs on, named by + * any ID the platform's driver reads. A device it cannot enrol is `DEVICE_NOT_ENROLLABLE` with + * the driver's reason; a leased, reclaiming or busy one is `DEVICE_BUSY`. A device already + * enrolled is enrolled again: its record is kept and its model, OS version, class and apps are + * read anew (`reenrolled: true`). A gateway answers `UNSUPPORTED_IN_GATEWAY_MODE`. + */ + addDevice(input: DeviceAddInput): Promise; + + /** + * ADR 0022 §2: forgets an enrolled physical device, named by its canonical ID. Nothing is sent + * to the device. `UNKNOWN_DEVICE` for an ID with no enrolled device, `DEVICE_BUSY` for a leased + * or reclaiming one. A gateway answers `UNSUPPORTED_IN_GATEWAY_MODE`. + */ + removeDevice(input: DeviceRemoveInput): Promise; } /** Internal: builds either client. `admin` toggles only which methods the returned object @@ -407,6 +431,8 @@ function buildDegradedClient( installComponent: () => rejected(), installComponentOnWorkers: () => rejected(), removeComponent: () => rejected(), + addDevice: () => rejected(), + removeDevice: () => rejected(), }; return client; } @@ -653,6 +679,14 @@ class SimlockClientImpl { return this.#call("component.remove", input); } + addDevice(input: DeviceAddInput): Promise { + return this.#call("device.add", input); + } + + removeDevice(input: DeviceRemoveInput): Promise { + return this.#call("device.remove", input); + } + // ---- abort (ADR §10) ------------------------------------------------------------------------ /** diff --git a/src/simlock-client/types.ts b/src/simlock-client/types.ts index d290c3e63..21b32b7e7 100644 --- a/src/simlock-client/types.ts +++ b/src/simlock-client/types.ts @@ -89,6 +89,10 @@ export type WorkerInstallComponentInput = OpInput<"worker.install-component">; export type WorkerInstallComponentOutput = OpOutput<"worker.install-component">; export type ComponentRemoveInput = OpInput<"component.remove">; export type ComponentRemoveOutput = OpOutput<"component.remove">; +export type DeviceAddInput = OpInput<"device.add">; +export type DeviceAddOutput = OpOutput<"device.add">; +export type DeviceRemoveInput = OpInput<"device.remove">; +export type DeviceRemoveOutput = OpOutput<"device.remove">; // ---- pushes --------------------------------------------------------------------------------- diff --git a/ui/src/views/worker-detail.tsx b/ui/src/views/worker-detail.tsx index 816e8a6ec..dd425badb 100644 --- a/ui/src/views/worker-detail.tsx +++ b/ui/src/views/worker-detail.tsx @@ -146,10 +146,15 @@ export function DeviceTable({ numeric: true, }, { cell: (device) => platformName(device.spec.platform), header: "Platform" }, + { cell: (device) => (device.physical ? "Physical" : "Virtual"), header: "Type" }, { cell: (device) => device.spec.model, header: "Model" }, { cell: (device) => device.spec.osVersion, header: "Runtime", mono: true }, - { cell: (device) => device.mode, header: "Mode" }, - { cell: (device) => device.spec.imageTag ?? "—", header: "Image tag", mono: true }, + { cell: (device) => (device.physical ? "—" : device.mode), header: "Mode" }, + { + cell: (device) => (device.physical ? "—" : (device.spec.imageTag ?? "—")), + header: "Image tag", + mono: true, + }, ]; return ( Date: Sun, 11 Oct 2026 09:57:05 +0200 Subject: [PATCH 03/22] docs: device add and device remove, both EVENTS.md, the status driverDeviceId (#437) --- docs/CLI.md | 63 ++++++++++++++++++++++++++++++++++ docs/CLIENT.md | 38 ++++++++++++++++++++- docs/EVENTS.md | 3 +- docs/HTTP-API.md | 67 +++++++++++++++++++++++++++++++++---- docs/internal/COMPONENTS.md | 5 +-- docs/internal/EVENTS.md | 3 +- 6 files changed, 168 insertions(+), 11 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 141ef2a9b..c9bc05165 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -73,6 +73,7 @@ command starts it again) to bring the platform up. | 10 | `EXEC_TIMEOUT` | a `simctl`/`adb` command run through `device.exec` outlived `exec.timeoutMs` and was killed | | 10 | `DOWNLOAD_TIMEOUT` | a runtime download, including the time spent waiting for another download on the same platform, outlived `downloads.timeoutMs` | | 11 | `NO_CAPACITY` | capacity reached and `--no-wait` was set, or, through a gateway, no worker that takes requests can serve the request | +| 11 | `DEVICE_BUSY` | `device add` or `device remove` of a device that is leased, being reclaimed or being enrolled or removed by another command; try again once that ends | | 11 | `COMPONENT_BUSY` | `component remove` while a component install or removal runs or waits on that platform; try again once it ends | | 12 | `NO_DRIVER` | no driver registered for the requested platform | | 12 | `RUNTIME_MISSING` | runtime not installed and no `--allow-download`, or no installed image of the `--image-tag` asked for | @@ -82,6 +83,8 @@ command starts it again) to bring the platform up. | 12 | `UNKNOWN_WORKER` | `worker drain`/`undrain` or `component install --worker` naming a worker the gateway does not know | | 12 | `DOWNLOADS_DISABLED` | `component install` on a machine whose `downloads.policy` is `"never"` | | 12 | `COMPONENT_NOT_OWNED` | `component remove` of a component Simlock did not install, or one that changed on disk since | +| 12 | `DEVICE_NOT_ENROLLABLE` | `device add` of a device that cannot be enrolled; the message says why | +| 12 | `UNKNOWN_DEVICE` | `device remove` of an ID that is not an enrolled device | | 12 | `COMPONENT_IN_USE` | `component remove` of a component a device uses, Simlock's or your own | | 13 | `REQUESTER_ALREADY_LEASED` | requester already holds a lease or has a pending request — one lease per agent in v1; release the named lease first | | 13 | `LEASE_ID_TAKEN` | `lease --lease-id` named an ID an active lease or a waiting request already holds | @@ -1193,6 +1196,10 @@ schema's source of truth. ## `simlock status` +In `status --json`, an enrolled physical device's entry carries `driverDeviceId`, +its canonical ID, to every session including an agent's: it is the ID +`simlock device remove` takes. A simulator or emulator's entry has none. + Below the capacity lines, `status` prints the warm pool: whether it is on, the running slots it holds back per platform, and a line for each kind of device the targets name, with how many devices it wants, how many are ready and how many @@ -1758,6 +1765,62 @@ Simlock counts only the devices it can see. A device you create while a removal runs, or a device of another Simlock instance on the same machine, is not counted. +## `simlock device add --platform ` / `device remove ` + +Enrols a phone or tablet plugged into this machine, and forgets it again. +Admin only: an agent session gets `FORBIDDEN`. Both run on the machine the +device is plugged into, a single host or a worker. A gateway answers +`UNSUPPORTED_IN_GATEWAY_MODE`: run the command on the worker the device is +plugged into. + +```sh +simlock device add --platform ios 00008110-001A2B3C4D5E +# Enrolled iPhone 15 Pro (iOS 18.4) as 00008110-001A2B3C4D5E, 12 user apps recorded. +simlock device remove 00008110-001A2B3C4D5E +# Removed 00008110-001A2B3C4D5E. It is no longer enrolled; nothing on the device changed. +``` + +`device add` asks the platform's driver to read the device and writes a +record only when the driver says the device can be enrolled. The record is +kept under the device's one canonical ID, whatever ID you typed: for an +iPhone it is the UDID, even when you typed its CoreDevice identifier. +`--platform` is required, because the platform decides which driver reads the +device. `--json` prints the operation's output instead of the line: + +```text +{"id":"dev_8f2a","driverDeviceId":"00008110-001A2B3C4D5E","platform":"ios","model":"iPhone 15 Pro","osVersion":"18.4","class":"phone","appCount":12,"reenrolled":false} +``` + +Running `device add` again on an enrolled device, by any ID it answers to, +enrols it again: the record and its `id` stay, and the model, OS version, +class and app list are read anew (`reenrolled` is `true`, and the line says +`again`). A device in quarantine comes back `ready`. A device you removed and +add again gets a new record. + +`device add` fails, and writes nothing, with: + +- `DEVICE_NOT_ENROLLABLE` (exit 12), with a reason: `not-connected`, + `network-only`, `untrusted`, `developer-mode-off`, `os-too-old`, + `unsupported-kind`, `locked`, `unreadable`, `virtual-device` (a simulator or + emulator, including every one Simlock manages) or + `enrolled-on-other-platform`. The message is the driver's own, and says + what to do. +- `DEVICE_BUSY` (exit 11): the device is `leased`, is being `reclaiming`, or + another `device add` or `device remove` holds it (`busy`). +- `NO_DRIVER` (exit 12): this machine has no driver for the platform. + +`device remove` takes the canonical ID, which `simlock status --json` and +`simlock list --devices` show as `driverDeviceId`. It asks for no +confirmation and takes no `--yes`: it only makes Simlock forget the device, +and sends nothing to it. An ID that is not enrolled is `UNKNOWN_DEVICE` +(exit 12); a leased or reclaiming device is `DEVICE_BUSY`. + +Enrolling emits `device.enrolled` and removing emits `device.deleted` with +`initiator` `operator`; see [EVENTS.md](EVENTS.md). The real iOS and Android +drivers do not read physical devices yet and refuse every ID as +`not-connected`. A missing `--platform`, a second ID or an unknown option is a +usage error (exit 2). + ## `simlock cleanup [--dry-run] [--rule ]` Run the cleanup reconciliation immediately. `--dry-run` prints the actions diff --git a/docs/CLIENT.md b/docs/CLIENT.md index bb959dd1f..6b578c187 100644 --- a/docs/CLIENT.md +++ b/docs/CLIENT.md @@ -16,7 +16,8 @@ import { connectSimlockAdmin } from "simlock/admin"; // agent + admin role `connectSimlockAdmin` returns a superset of `connectSimlock`'s client — every agent-role method plus the admin-role ones (`list`, `runCleanup`, `runNuke`, `getConfig`, `stopDaemon`, `replayEvents`/`subscribeEvents` (each event carries an `id`), -`createToken`/`listTokens`/`revokeToken`, `installComponent`, `removeComponent`). The split exists so +`createToken`/`listTokens`/`revokeToken`, `installComponent`, `removeComponent`, +`addDevice`, `removeDevice`). The split exists so `simlock/client` doesn't even show admin methods in a caller's editor; the daemon's own role check is what actually stops an agent-role session from calling one — this is a discoverability choice, not the @@ -490,6 +491,41 @@ try { } ``` +## Enrolling a physical device: `addDevice` and `removeDevice` + +`addDevice({ platform, id })` on the admin client enrols a phone or tablet +plugged into the machine the daemon runs on, the call behind +`simlock device add`. `removeDevice({ id })` forgets it, the call behind +`simlock device remove`. Neither asks for confirmation, and `removeDevice` +sends nothing to the device. + +```ts +const device = await admin.addDevice({ platform: "ios", id: "00008110-001A2B3C4D5E" }); +// { id: "dev_8f2a", driverDeviceId: "00008110-001A2B3C4D5E", platform: "ios", +// model: "iPhone 15 Pro", osVersion: "18.4", class: "phone", appCount: 12, +// reenrolled: false } +await admin.removeDevice({ id: device.driverDeviceId }); +// { id: "00008110-001A2B3C4D5E", driverDeviceId: "00008110-001A2B3C4D5E" } +``` + +- `id` in `addDevice`'s result is the registry id; `driverDeviceId` is the + canonical ID that `removeDevice` takes, and that `getStatus()` shows on a + physical device's entry. An `id` you pass may be any ID the device answers + to. Enrolling an enrolled device again keeps its registry id and reads + `reenrolled: true`. +- `addDevice` rejects, writing nothing, with `DEVICE_NOT_ENROLLABLE` + (`details` carries `id`, `platform`, `reason` and, when known, + `canonicalId`; `reason` is `not-connected`, `network-only`, `untrusted`, + `developer-mode-off`, `os-too-old`, `unsupported-kind`, `locked`, + `unreadable`, `virtual-device` or `enrolled-on-other-platform`), with + `DEVICE_BUSY` (`details.reason` is `leased`, `reclaiming` or `busy`) and + with `NO_DRIVER`. +- `removeDevice` rejects with `UNKNOWN_DEVICE` for an ID that is not + enrolled, and with `DEVICE_BUSY`. +- Both reject with `FORBIDDEN` for an agent session and with + `UNSUPPORTED_IN_GATEWAY_MODE` from a gateway: connect to the worker the + device is plugged into. + ## What machine answered: `getStatus().host` `getStatus()` carries a `host` block beside `daemon`: the machine the daemon diff --git a/docs/EVENTS.md b/docs/EVENTS.md index 44793d902..e01475529 100644 --- a/docs/EVENTS.md +++ b/docs/EVENTS.md @@ -47,7 +47,8 @@ the gateway's own lease index before any worker is ever contacted. `lease-id-tak | `device.quarantine-abandoned` | device id, attempts | a quarantined device exhausted its configured retry budget (`warmPool.quarantine.maxRetries`) — purge retries, or delete retries for a `fresh` device — and was destroyed | QuarantineCoordinator | implemented | | `device.quarantine-stranded` | device id, attempts, stable error summary | a quarantined device exhausted its retry budget (purge or delete retries) and the destroy that should have retired it also failed; it stays `quarantined` with no further retry until an operator intervenes | QuarantineCoordinator | implemented | | `device.shutdown` | device id, initiator (rule/command; `warm-pool` when the warm pool shut an idle device down, over the running limit, with `warmPool.enabled: false`, to keep `warmPool.reserveRunning` running slots free, or because a ready device that never served a lease and that no `warmPool.targets` entry keeps has been idle past `idle.shutdownAfterMs`) | device stopped, still on disk | Registry; the reclaim coordinator for interrupted reclaim recovery | implemented | -| `device.deleted` | device id, initiator (`lease-end` when a `fresh` device's lease ended and its delete completed, including a delete retried from quarantine; `doctor` when `doctor --fix`, or a daemon start that found a leased device gone from its platform, marked it missing) | device removed from disk and registry | Registry | implemented | +| `device.deleted` | device id, initiator (`lease-end` when a `fresh` device's lease ended and its delete completed, including a delete retried from quarantine; `doctor` when `doctor --fix`, or a daemon start that found a leased device gone from its platform, marked it missing; `operator` when `device remove` unenrolled a physical device), physical (`true` for an enrolled physical device, `false` for a simulator or emulator) | device removed from the registry, and from disk for a simulator or emulator; a physical device is only forgotten, nothing on it changes | Registry | implemented | +| `device.enrolled` | device id, platform, driver device id (the canonical ID), model, OS version, class (phone/tablet), app count, reenrolled | `device add` wrote a physical device `ready`; `reenrolled` is `true` when the device already had a record, which kept its device id and took a fresh read of its model, OS version, class and apps | Registry | implemented | | `device.foreign-state-detected` | device id, platform, expected (running/stopped), observed (running/stopped) | doctor reconcile found a managed device's observed boot state disagreeing with the committed registry state | Doctor | implemented | | `device.foreign-provenance-detected` | device id, platform, detail (erased/mark-mismatch/durable-mark-missing) | doctor reconcile found a managed device's provenance marks no longer proving Simlock owns it | Doctor | implemented | | `device.stalled-transition-detected` | device id, platform, state (provisioning/reclaiming), age, threshold | doctor reconcile found a `provisioning`/`reclaiming` device whose time in that state exceeds a driver-derived threshold — the driver call meant to resolve the transition never did | Doctor | implemented | diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 493a5b3c1..74048ee8c 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -114,6 +114,9 @@ called out where it applies below: - **The lease's `slim` boolean is gone; it carries `mode` instead** — `"slim"` or `"full"`, the device mode of the granted device. Every device in `GET /v1/status` carries the same `mode`. +- **A physical device carries `driverDeviceId`** in `GET /v1/status`, its canonical + ID, to every role: the ID `DELETE /v1/devices/{id}` takes. A simulator or + emulator has none there. - **A device carries `physical`**, `false` for a simulator or emulator and `true` for a real phone or tablet, in `GET /v1/status` and its `spec`. A physical device has a `class` (`phone` or `tablet`) and no `mode`, and so does @@ -124,8 +127,9 @@ Routes, status codes, and every other field are unchanged. Gateway/worker fleet mode is purely additive on top of that: the same routes answer identically whether the daemon behind them is a worker or a **gateway** fronting a fleet of workers (except -`POST /v1/components/install`, `GET /v1/components` and -`DELETE /v1/components/{platform}/{version}`, which only a worker serves; a +`POST /v1/components/install`, `GET /v1/components`, +`DELETE /v1/components/{platform}/{version}`, `POST /v1/devices` and +`DELETE /v1/devices/{id}`, which only a worker serves; a gateway answers `501 UNSUPPORTED_IN_GATEWAY_MODE`), and what it adds are new routes (`/v1/uplink`, `/v1/workers*`, `POST /v1/leases/{id}/exec`), new fields (`mode` on status, `workers[]`, `workerId`, `worker` on the lease), @@ -1049,6 +1053,57 @@ platform. Each refusal removes nothing: { "error": { "code": "COMPONENT_IN_USE", "message": "...", "devices": 1, "foreignDevices": 2 } } ``` +### `POST /v1/devices` + +Role: `operator`. Enrols a phone or tablet plugged into the machine the +daemon runs on, the same operation as `simlock device add`. The body is +exactly `platform` and `id`; any other field is `400 BAD_REQUEST`. + +```json +{ "platform": "ios", "id": "00008110-001A2B3C4D5E" } +``` + +→ `200` + +```json +{ "id": "dev_8f2a", "driverDeviceId": "00008110-001A2B3C4D5E", "platform": "ios", + "model": "iPhone 15 Pro", "osVersion": "18.4", "class": "phone", "appCount": 12, + "reenrolled": false } +``` + +`id` is the registry id; `driverDeviceId` is the canonical ID, which +`DELETE /v1/devices/{id}` takes. Enrolling an enrolled device again keeps its +registry id and answers `reenrolled: true`. + +- `422 DEVICE_NOT_ENROLLABLE` — the driver refused, or the ID names a + simulator or emulator, or a device enrolled on the other platform. The body + carries `id` (as sent), `platform`, `reason` and, when known, + `canonicalId`. +- `409 DEVICE_BUSY` — the device is leased, reclaiming or held by another + enrollment. The body carries `id` and `reason` (`leased`, `reclaiming` or + `busy`). +- `422 NO_DRIVER` — no driver for the platform. +- `403 FORBIDDEN` — an `agent` token. +- `400 BAD_REQUEST` — an unknown field, a platform other than `ios` or + `android`, or an `id` over 200 characters, with whitespace or a control + character, or starting with `-`. +- `501 UNSUPPORTED_IN_GATEWAY_MODE` — the daemon is a gateway; ask the worker + the device is plugged into. + +### `DELETE /v1/devices/{id}` + +Role: `operator`. Forgets an enrolled phone or tablet, the same operation as +`simlock device remove`. `{id}` is the canonical ID (`driverDeviceId`), +URL-encoded. Nothing is sent to the device. + +→ `200 { "id": "00008110-001A2B3C4D5E", "driverDeviceId": "00008110-001A2B3C4D5E" }` + +- `404 UNKNOWN_DEVICE` — no enrolled device has that ID. +- `409 DEVICE_BUSY` — the device is leased, reclaiming or held by another + enrollment. +- `403 FORBIDDEN` — an `agent` token. +- `501 UNSUPPORTED_IN_GATEWAY_MODE` — the daemon is a gateway. + ### `DELETE /v1/leases/{id}` Role: `agent` (own lease); `operator` may release any lease. @@ -1377,10 +1432,10 @@ Every failure is the same shape the daemon protocol uses: | 400 | `BAD_REQUEST` (malformed body, bad query param, validation) | | 401 | `UNAUTHENTICATED` (missing or unrecognized token) | | 403 | `FORBIDDEN` (role doesn't permit the route — including a `worker` token on any `/v1` route other than `/v1/uplink`, and an `agent`/`operator` token at `/v1/uplink`; a `/v1/lease-requests/*` route whose request another token sent; or `POST /v1/leases/{id}/renew`/`DELETE /v1/leases/{id}`/`POST /v1/leases/{id}/exec` naming another requester's still-live lease), `DOWNLOADS_DISABLED` (`POST /v1/components/install` under `downloads.policy: "never"`) | -| 404 | `UNKNOWN_WORKER` (`POST`/`DELETE /v1/workers/{id}/drain` naming a worker the gateway does not know), `UNKNOWN_LEASE_REQUEST` (unknown request id), `UNKNOWN_LEASE` (unknown lease id, expired/released, **or `GET /v1/leases/{id}`/`GET /v1/leases/{id}/events` naming another requester's lease** — see [`GET /v1/leases/{id}`](#get-v1leasesid)) | -| 409 | `REQUESTER_ALREADY_LEASED` (body names the existing lease id; fleet-wide on a gateway), `LEASE_ID_TAKEN` (a `leaseId` an active lease or a waiting request holds; body carries `leaseId`; on a gateway, one of the gateway's own leases or requests, or a worker that refused it), `IDEMPOTENCY_CONFLICT` (an `Idempotency-Key` repeated with a different device or `leaseId`), `REQUEST_NOT_CANCELLABLE` (body names the lease id if the request had already been granted), `WORKER_CONNECTED` (`DELETE /v1/workers/{id}` while its uplink is open), `COMPONENT_NOT_OWNED`, `COMPONENT_IN_USE` (body carries `devices` and `foreignDevices`), `COMPONENT_BUSY` (the three refusals of `DELETE /v1/components/{platform}/{version}`) | -| 422 | `UNKNOWN_MODEL`, `RUNTIME_MISSING`, `NO_DRIVER`, `PASSTHROUGH_REFUSED` (a refused `exec` verb, a caller-supplied `--set`/`-P`, a bare `adb shell`), `UNKNOWN_PASSTHROUGH_TOOL` | -| 501 | `UNSUPPORTED_IN_GATEWAY_MODE` (an operation that acts on one machine, asked of a gateway: `POST /v1/components/install`, `GET /v1/components`, `DELETE /v1/components/{platform}/{version}`), `UNSUPPORTED_IN_WORKER_MODE` (an operation on a gateway's workers, asked of a single host: `POST`/`DELETE /v1/workers/{id}/drain`, `DELETE /v1/workers/{id}`, `POST /v1/components/install` with `workers`) | +| 404 | `UNKNOWN_WORKER` (`POST`/`DELETE /v1/workers/{id}/drain` naming a worker the gateway does not know), `UNKNOWN_LEASE_REQUEST` (unknown request id), `UNKNOWN_LEASE` (unknown lease id, expired/released, **or `GET /v1/leases/{id}`/`GET /v1/leases/{id}/events` naming another requester's lease** — see [`GET /v1/leases/{id}`](#get-v1leasesid)) , `UNKNOWN_DEVICE` (`DELETE /v1/devices/{id}` naming a device that is not enrolled) | +| 409 | `REQUESTER_ALREADY_LEASED` (body names the existing lease id; fleet-wide on a gateway), `LEASE_ID_TAKEN` (a `leaseId` an active lease or a waiting request holds; body carries `leaseId`; on a gateway, one of the gateway's own leases or requests, or a worker that refused it), `IDEMPOTENCY_CONFLICT` (an `Idempotency-Key` repeated with a different device or `leaseId`), `REQUEST_NOT_CANCELLABLE` (body names the lease id if the request had already been granted), `WORKER_CONNECTED` (`DELETE /v1/workers/{id}` while its uplink is open), `COMPONENT_NOT_OWNED`, `COMPONENT_IN_USE` (body carries `devices` and `foreignDevices`), `COMPONENT_BUSY` (the three refusals of `DELETE /v1/components/{platform}/{version}`), `DEVICE_BUSY` (`POST /v1/devices`, `DELETE /v1/devices/{id}` of a leased, reclaiming or held device; body carries `id` and `reason`) | +| 422 | `UNKNOWN_MODEL`, `RUNTIME_MISSING`, `NO_DRIVER`, `DEVICE_NOT_ENROLLABLE` (`POST /v1/devices`; body carries `id`, `platform`, `reason`, and `canonicalId` when known), `PASSTHROUGH_REFUSED` (a refused `exec` verb, a caller-supplied `--set`/`-P`, a bare `adb shell`), `UNKNOWN_PASSTHROUGH_TOOL` | +| 501 | `UNSUPPORTED_IN_GATEWAY_MODE` (an operation that acts on one machine, asked of a gateway: `POST /v1/components/install`, `GET /v1/components`, `DELETE /v1/components/{platform}/{version}`, `POST /v1/devices`, `DELETE /v1/devices/{id}`), `UNSUPPORTED_IN_WORKER_MODE` (an operation on a gateway's workers, asked of a single host: `POST`/`DELETE /v1/workers/{id}/drain`, `DELETE /v1/workers/{id}`, `POST /v1/components/install` with `workers`) | | 503 | `NO_CAPACITY` (with `noWait: true`, or, on a gateway, when no worker that takes requests can serve the request; response carries `Retry-After`), `WORKER_UNREACHABLE` (a gateway could not reach the worker holding this lease or request) | | 504 | `EXEC_TIMEOUT` (a `device.exec` command outlived `exec.timeoutMs`), `DOWNLOAD_TIMEOUT` (a runtime download, waiting for another download included, outlived `downloads.timeoutMs`) | diff --git a/docs/internal/COMPONENTS.md b/docs/internal/COMPONENTS.md index f7f785383..5a3cfefbb 100644 --- a/docs/internal/COMPONENTS.md +++ b/docs/internal/COMPONENTS.md @@ -61,13 +61,14 @@ pool reads from acquisition. |---|---|---|---| | `Registry` | `src/core/registry.ts` | Devices (virtual in `devices`, physical in `physicalDevices`, ADR 0022 §1), leases, lease requests and component records, written through one commit to `state.json`; the one virtual read, `snapshot.virtualDevices`; the device transition function's only caller; emits the post-commit device and lease facts. | Decide a transition: callers do, inside a decision section. | | `SerializedDecision` | `src/core/serialized-decision.ts` | Serialising short read-decide-commit sections. | Hold driver work or other long I/O. | -| `DeviceOperationClaims` | `src/core/device-operation-claims.ts` | Exclusive per-device operation claims: boot, eviction, cleanup, nuke, reclaim. | Any lifecycle, cleanup or leasing policy. | +| `DeviceOperationClaims` | `src/core/device-operation-claims.ts` | Exclusive per-device operation claims: boot, eviction, cleanup, nuke, reclaim, enrollment. | Any lifecycle, cleanup or leasing policy. | +| `PhysicalEnrollment` | `src/core/physical-enrollment.ts` | `device add` and `device remove` (ADR 0022 §2): refusing Simlock's own simulators and emulators, relaying the driver's read and its refusals, finding the one record a canonical ID has, taking the `enrollment` claim from the second read to the rewrite, and the busy check `add` and `remove` share. Built by `createCore` as `core.enrollment`. | Read a device or word a refusal (the driver does); write a record (the registry does); run on a gateway. | | `DeviceProvisioner` | `src/core/device-provisioner.ts` | Creating a device: the component-removal gate, the registry record, the driver's `provision`, readiness for the lease handoff. | Decide whether to provision. | | `ManagedDeviceLifecycle` | `src/core/managed-device-lifecycle.ts` | Registry-owned device operations with a claim and a revalidation each: boot for a lease, boot back to warm, shutdown, destroy, dispose, recover a leased device. Every driver verb on an existing device goes through here. | Decide when to run them. | | `ReclaimCoordinator` | `src/core/reclaim-coordinator.ts` | After a release: the driver's reclaim (none for a physical device, which stays `reclaiming` until its own reclaim exists), committing the state it returns (`shutdown` on iOS, `ready` on Android) and waking the queue, handing a failed purge to quarantine, deleting a spent `fresh` device, recovering an interrupted reclaim at startup (a shutdown; for a device whose wipe a start put off, the full reclaim started in the background under a claim, and `settle` for a graceful stop). | Decide whether a device stays warm, boot anything, or read capacity or the queue. The warm pool (`src/core/warm-pool/`, ADR 0017) does. | | `WarmPool`, `policy.ts` | `src/core/warm-pool/` | What stays warm: one pass at a time after the bus facts that change the budget and a 30 s tick; the pure policy proposes shutdowns over the budget (LRU, never a device that serves a waiting request) and boots for a waiting request or a recently released device with room; `warmPool.enabled`, `warmPool.reserveRunning` (running slots idle devices may not fill) and their config; `warmPool.targets`: resolves each target through the acquisition coordinator's `resolve`, boots or creates devices of that kind up to `maxConcurrentBoots` at once (`retry.ts` remembers failures), reports what each kind of device is short of (`figures()`, which status, doctor and the console read, and the `warm-pool.target-missed` event on each report's edge into short, kept per configured target), names the targeted devices the idle shutdown timer leaves alone, and shuts down a never-leased device no target keeps. | Reclaim; grant; decide the running limit (`capacity/`); resolve a request into a spec (the coordinator does); act on a device without revalidating it (it goes through `ManagedDeviceLifecycle` or `DeviceProvisioner`). | | Idle order | `src/core/idle-order.ts` | LRU order and victim selection over idle devices, used by the planner's eviction and the warm pool. | Act. | -| `QuarantineCoordinator` | `src/core/quarantine-coordinator.ts` | The quarantine lifecycle: entry from a failed purge, a failed spent delete or a stalled transition; retry on backoff (armed for a virtual device only); give up by destroying. | Pick which devices are grantable: every grant path selects by exact state. | +| `QuarantineCoordinator` | `src/core/quarantine-coordinator.ts` | The quarantine lifecycle: entry from a failed purge, a failed spent delete or a stalled transition; retry on backoff (armed for a virtual device only) under the device's `reclaim` claim, waiting a backoff when the claim is held; give up by destroying. | Pick which devices are grantable: every grant path selects by exact state. | | `CapacityCoordinator`, strategies, `CapacityObserver` | `src/core/capacity/` | Limits and the RAM budget behind a pluggable `CapacityStrategy` (`resource`, `fixed`); provisioning, running and boot reservations; the figures status and `capacity.changed` report. | Know about queueing, device selection, the registry or drivers. | | `readStartup`, `StartupRead` | `src/core/startup-read.ts` | Startup's one read of the machine: `listManaged` once per driver, side by side, each bounded by a fixed 60-second limit on the injected clock; a platform with no driver, or whose listing throws or passes the limit, is unreadable and left out. Doctor's startup pass, leasing's reconciler and device convergence all take it. | Throw, or fail startup for one platform. Read a second time. | | `StartupConverger` | `src/core/startup-converger.ts` | The device steps of startup, in order: re-arm quarantine, recover interrupted reclaims, delete spent devices. Leasing's reconciler runs before it. | Emit events: recovery and cleanup own their facts. Act on a device whose platform the startup read could not list. | diff --git a/docs/internal/EVENTS.md b/docs/internal/EVENTS.md index ef78c02e0..f5786f3f2 100644 --- a/docs/internal/EVENTS.md +++ b/docs/internal/EVENTS.md @@ -85,7 +85,8 @@ worker is ever contacted. `lease-id-taken` (ADR 0020; widens a published vocabul | `device.quarantine-abandoned` | device id, attempts | a quarantined device exhausted its configured retry budget (`warmPool.quarantine.maxRetries`) — purge retries, or delete retries for a spent `fresh` device — and was destroyed | QuarantineCoordinator | implemented | | `device.quarantine-stranded` | device id, attempts, stable error summary | a quarantined device exhausted its retry budget (purge or delete retries) and the destroy that should have retired it also failed; it stays `quarantined` with no further retry until an operator intervenes | QuarantineCoordinator | implemented | | `device.shutdown` | device id, initiator (rule/command; `warm-pool` when the warm pool shut an idle device down, over the running limit, with `warmPool.enabled: false`, to keep `warmPool.reserveRunning` running slots free, or because a ready device that never served a lease and that no `warmPool.targets` entry keeps has been idle past `idle.shutdownAfterMs`) | device stopped, still on disk | Registry; ReclaimCoordinator for interrupted reclaim recovery | implemented | -| `device.deleted` | device id, initiator (`lease-end` when a `fresh` device's lease ended and its delete completed — from ReclaimCoordinator, at startup convergence, or retried from quarantine; #75. `doctor` when `doctor --fix` marked a missing device, or startup's lease reconciler found a leased device absent from a platform it could read and ended the lease in the same write, ADR 0019 §2) | device removed from disk and registry | Registry | implemented | +| `device.deleted` | device id, initiator (`lease-end` when a `fresh` device's lease ended and its delete completed — from ReclaimCoordinator, at startup convergence, or retried from quarantine; #75. `doctor` when `doctor --fix` marked a missing device, or startup's lease reconciler found a leased device absent from a platform it could read and ended the lease in the same write, ADR 0019 §2. `operator` when `device remove` unenrolled a physical device, ADR 0022 §2), physical (the record's own flag: `true` for an enrolled physical device, `false` for a simulator or emulator; additive, events rule 6) | device removed from the registry, and from disk for a virtual device; a physical device is only forgotten and nothing is sent to it | Registry | implemented | +| `device.enrolled` | device id, platform, driver device id (the canonical ID), model, OS version, class (phone/tablet), app count, reenrolled | an admin's `device add` wrote a physical device `ready` (ADR 0022 §2). `reenrolled` is `true` when the device already had a `ready` or `quarantined` record, which kept its registry ID and took a fresh read of model, OS version, class and apps | Registry | implemented | | `device.foreign-state-detected` | device id, platform, expected (running/stopped), observed (running/stopped) | doctor reconcile found a managed device's observed boot state disagreeing with the committed registry state | Doctor | implemented | | `device.foreign-provenance-detected` | device id, platform, detail (erased/mark-mismatch/durable-mark-missing) | doctor reconcile found a managed device's provenance marks no longer proving Simlock owns it | Doctor | implemented | | `device.stalled-transition-detected` | device id, platform, state (provisioning/reclaiming), age, threshold | doctor reconcile found a `provisioning`/`reclaiming` device whose time in that state exceeds a driver-derived threshold (`stalledTransition.thresholdMultiplier` over `Driver.estimate`, floored at `stalledTransition.minimumThresholdMs`) — the driver call meant to resolve the transition never did | Doctor | implemented | From fb19bdb9620610171fc2d5cfb8b5bb51f81469f8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 10:00:49 +0200 Subject: [PATCH 04/22] test: unit tests for enrollment, quarantine claim, registry, contract, drivers, console (#437) --- src/contract/operations.test.ts | 31 ++ src/contract/schemas.ts | 2 +- src/core/doctor.test.ts | 82 +++++ src/core/driver.test.ts | 28 ++ src/core/physical-enrollment.test.ts | 411 ++++++++++++++++++++++++ src/core/quarantine-coordinator.test.ts | 108 +++++++ src/core/registry.test.ts | 143 ++++++++- src/drivers/android/index.test.ts | 21 ++ src/drivers/ios/index.test.ts | 20 ++ src/leasing/create-leasing.test.ts | 51 +++ ui/src/views/worker-detail.test.tsx | 34 ++ 11 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 src/core/physical-enrollment.test.ts diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index d4b25543b..62fcf235a 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -66,6 +66,9 @@ const ROLE_MATRIX: ReadonlyArray<{ }, // ADR 0010 §8: removal is destructive, so admin-only. { name: "component.remove", input: { platform: "ios", version: "26.4" }, role: "admin" }, + // ADR 0022 §2: enrolling and unenrolling a physical device is an operator's decision. + { name: "device.add", input: { id: "00008110-001A2B3C4D5E", platform: "ios" }, role: "admin" }, + { name: "device.remove", input: { id: "00008110-001A2B3C4D5E" }, role: "admin" }, ]; describe("operation role matrix", () => { @@ -133,6 +136,8 @@ const EFFECT_MATRIX: ReadonlyArray<{ effect: "write", }, { name: "component.remove", input: { platform: "ios", version: "26.4" }, effect: "write" }, + { name: "device.add", input: { id: "00008110-001A2B3C4D5E", platform: "ios" }, effect: "write" }, + { name: "device.remove", input: { id: "00008110-001A2B3C4D5E" }, effect: "write" }, ]; function resolvedEffect(name: OperationName, input: unknown): unknown { @@ -1264,3 +1269,29 @@ describe("lease.request names a model, a class, or nothing (ADR 0015 §1)", () = expect(requestedClass({ model: "iPhone 16" })).toBeUndefined(); }); }); + +describe("device.add and device.remove ids (ADR 0022 §2)", () => { + const long = "x".repeat(201); + + it.each([ + ["201 characters", long], + ["a space", "00008110 001A2B3C4D5E"], + ["a tab", "00008110\t001A2B3C4D5E"], + ["a control character", "00008110\u0007001A2B3C4D5E"], + ["a leading dash", "-00008110-001A2B3C4D5E"], + ["nothing", ""], + ])("refuses an id with %s", (_case, id) => { + expect(OPERATIONS["device.add"].input.safeParse({ id, platform: "ios" }).success).toBe(false); + expect(OPERATIONS["device.remove"].input.safeParse({ id }).success).toBe(false); + }); + + it("takes an id of exactly 200 characters, and refuses a field it does not declare", () => { + const id = "x".repeat(200); + + expect(OPERATIONS["device.add"].input.safeParse({ id, platform: "ios" }).success).toBe(true); + expect( + OPERATIONS["device.add"].input.safeParse({ id: "A", platform: "ios", worker: "w1" }).success, + ).toBe(false); + expect(OPERATIONS["device.remove"].input.safeParse({ id: "A", yes: true }).success).toBe(false); + }); +}); diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 3861272e2..14039c579 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -253,7 +253,7 @@ export const statusDeviceSchema = z .superRefine((device, context) => checkDeviceType(device, context, true)) // `list.get` records carry a virtual device's driver address, and a worker view parses them // through this schema: it drops the address of a device whose canonical ID is not shown. - .transform((device) => { + .transform((device): typeof device => { if (device.physical || device.driverDeviceId === undefined) return device; const { driverDeviceId: _address, ...rest } = device; return rest; diff --git a/src/core/doctor.test.ts b/src/core/doctor.test.ts index d0d44a3ad..84bd80809 100644 --- a/src/core/doctor.test.ts +++ b/src/core/doctor.test.ts @@ -1315,6 +1315,88 @@ describe("Doctor", () => { }); }); + it("reports no live-drift finding for a quarantined simulator while its retry holds its claim, and registry-device-missing once the fake no longer lists it", async () => { + const clock = new FakeClock(10_000); + const eventBus = new EventBus(clock); + const registry = await Registry.load({ + clock, + eventBus, + filesystem: new MemoryFilesystem(), + idGenerator: sequence(), + statePath: "/state.json", + }); + const device = await readyDevice(registry, "simlock-quarantined", "ios"); + const lease = await registry.createLease({ + deviceId: device.id, + ownerId: "agent", + requesterId: "agent", + ttlDeadline: 99_999, + ttlMs: 60_000, + }); + await registry.beginRelease(lease.id); + await registry.enterQuarantine(device.id, 10_100); + const driver = new FakeDriver({ clock, platform: "ios", reclaimResult: "ready" }); + const listed = { + devices: [ + { + address: "simlock-quarantined-address", + deviceId: "simlock-quarantined", + driverData: {}, + runState: "running" as const, + }, + ], + processes: [], + }; + driver.setManagedReality(listed); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const original = driver.reclaim.bind(driver); + vi.spyOn(driver, "reclaim").mockImplementation(async (target, options) => { + await gate; + return original(target, options); + }); + const claims = new DeviceOperationClaims(); + const quarantine = new QuarantineCoordinator({ + claims, + clock, + config: { + maxRetries: 3, + maxRetryBackoffMs: 300_000, + retryBackoffMs: 30_000, + retryBackoffMultiplier: 2, + }, + decisions: new SerializedDecision(), + drivers: new DriverCatalog([driver]), + eventBus, + notifyAvailability: () => {}, + registry, + }); + quarantine.restore(); + clock.advance(100); + await vi.waitFor(() => expect(claims.isClaimed(device.id)).toBe(true)); + const reconcile = () => + new Doctor({ claims, clock, config: config(), drivers: [driver], eventBus, registry }) + .reconcile() + .then((report) => + report.findings.filter( + (finding) => "deviceId" in finding && finding.deviceId === device.id, + ), + ); + + const whileListed = await reconcile(); + driver.setManagedReality({ devices: [], processes: [] }); + const whileGone = await reconcile(); + release(); + + expect(whileListed).toEqual([]); + expect(whileGone).toEqual([ + expect.objectContaining({ kind: "registry-device-missing", platform: "ios" }), + ]); + quarantine.dispose(); + }); + it("--fix reconciles an unleased device's state to observed reality in both directions", async () => { const clock = new FakeClock(10_000); const eventBus = new EventBus(clock); diff --git a/src/core/driver.test.ts b/src/core/driver.test.ts index 46fa88ca4..f81c10b2c 100644 --- a/src/core/driver.test.ts +++ b/src/core/driver.test.ts @@ -7,6 +7,7 @@ import { type Driver, type DriverDevice, InsufficientDiskSpaceError, + type PhysicalInspection, sameReceipt, UnknownModelError, } from "./driver.js"; @@ -211,3 +212,30 @@ describe("the virtual-only driver and lifecycle calls (ADR 0022 §4, §5)", () = expect(virtualCallsCompile).toBeTypeOf("function"); }); }); + +describe("PhysicalInspection (ADR 0022 §5)", () => { + /** Never run: a refusal naming the core's own reason must not compile. */ + function refusalReasons(): void { + const refused: PhysicalInspection = { + enrollable: false, + message: "not plugged in", + reason: "not-connected", + }; + const refusedAsVirtual: PhysicalInspection = { + enrollable: false, + message: "a simulator", + reason: "virtual-device", + }; + const otherPlatform: PhysicalInspection = { + enrollable: false, + message: "enrolled as iOS", + // @ts-expect-error only the core can know a device is enrolled on the other platform + reason: "enrolled-on-other-platform", + }; + void [refused, refusedAsVirtual, otherPlatform]; + } + + it("lets a driver refuse with every reason but enrolled-on-other-platform", () => { + expect(refusalReasons).toBeTypeOf("function"); + }); +}); diff --git a/src/core/physical-enrollment.test.ts b/src/core/physical-enrollment.test.ts new file mode 100644 index 000000000..8e9e5b860 --- /dev/null +++ b/src/core/physical-enrollment.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it, vi } from "vitest"; + +import { EventBus } from "../bus/index.js"; +import { FakeClock, FakeSystemStats, MemoryFilesystem } from "../ports/index.js"; +import { type FakePhysicalDevice } from "./fake-driver.js"; +import type { PhysicalDevice } from "./domain.js"; +import { createCore, loadConfig, Registry } from "./index.js"; +import { FakeDriver, testComponentWiring } from "./testing.js"; + +const UDID = "00008110-001A2B3C4D5E"; +const ALIAS = "7A1C0E52-6B7D-4F1E-9C1A-2D3E4F5A6B7C"; + +const IPHONE: FakePhysicalDevice = { + apps: ["com.example.one", "com.example.two"], + class: "phone", + ids: [UDID, ALIAS], + model: "iPhone 15 Pro", + osVersion: "18.4", +}; + +async function build(physicalDevices: readonly FakePhysicalDevice[] = [IPHONE]) { + const clock = new FakeClock(1_000); + const eventBus = new EventBus(clock); + const driver = new FakeDriver({ clock, physicalDevices, platform: "ios" }); + const filesystem = new MemoryFilesystem(); + const systemStats = new FakeSystemStats({ cpuCount: 8, totalRamBytes: 32 * 1024 ** 3 }); + let next = 1; + const registry = await Registry.load({ + clock, + eventBus, + filesystem, + idGenerator: { generate: () => `${next++}` }, + statePath: "/state.json", + }); + const core = createCore({ + clock, + config: await loadConfig({ filesystem, systemStats }), + drivers: [driver], + eventBus, + registry, + systemStats, + ...testComponentWiring({ clock, drivers: [driver], eventBus, registry }), + }); + return { clock, core, driver, eventBus, registry }; +} + +type Harness = Awaited>; + +function physicalRecords(harness: Harness): PhysicalDevice[] { + return harness.registry.snapshot.devices.filter( + (device): device is PhysicalDevice => device.physical, + ); +} + +function live(harness: Harness): PhysicalDevice[] { + return physicalRecords(harness).filter((device) => device.state !== "deleted"); +} + +function enrolledEvents(harness: Harness) { + return harness.eventBus.replay().filter((entry) => entry.event === "device.enrolled"); +} + +/** Leases the enrolled device and releases it into quarantine, as a failed purge would. */ +async function quarantine(harness: Harness, deviceId: string): Promise { + const lease = await harness.registry.createLease({ + deviceId, + ownerId: "agent", + requesterId: "agent", + ttlDeadline: 61_000, + ttlMs: 60_000, + }); + await harness.registry.beginRelease(lease.id); + await harness.registry.enterQuarantine(deviceId, 9_000); +} + +/** Holds the n-th `inspectPhysical` call until `release` runs; the others answer at once. */ +function holdInspection(driver: FakeDriver, callNumber: number) { + const original = driver.inspectPhysical.bind(driver); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const reached = vi.fn(); + vi.spyOn(driver, "inspectPhysical").mockImplementation(async (id) => { + calls += 1; + if (calls === callNumber) { + reached(); + await gate; + } + return original(id); + }); + return { reached, release }; +} + +describe("PhysicalEnrollment", () => { + it("records the apps the driver lists as the record's enrolledApps", async () => { + const harness = await build(); + + const { device, reenrolled } = await harness.core.enrollment.add("ios", UDID); + + expect(reenrolled).toBe(false); + expect(device.enrolledApps).toEqual(["com.example.one", "com.example.two"]); + expect(live(harness)[0]?.enrolledApps).toEqual(["com.example.one", "com.example.two"]); + expect(live(harness)[0]?.driverDeviceId).toBe(UDID); + }); + + it("re-enrolls with the model, OS version, class and apps the fake now reports, and the same registry ID", async () => { + const harness = await build(); + const first = await harness.core.enrollment.add("ios", UDID); + harness.driver.setPhysicalDevices([ + { + apps: ["com.example.three"], + class: "tablet", + ids: [UDID, ALIAS], + model: "iPad Pro", + osVersion: "18.5", + }, + ]); + + const again = await harness.core.enrollment.add("ios", ALIAS); + + expect(again.reenrolled).toBe(true); + expect(again.device.id).toBe(first.device.id); + expect(live(harness)).toHaveLength(1); + expect(live(harness)[0]).toMatchObject({ + enrolledApps: ["com.example.three"], + id: first.device.id, + spec: { class: "tablet", model: "iPad Pro", osVersion: "18.5" }, + state: "ready", + }); + expect(enrolledEvents(harness).map((entry) => entry.payload)).toMatchObject([ + { appCount: 2, reenrolled: false }, + { appCount: 1, class: "tablet", reenrolled: true }, + ]); + }); + + it("leaves one non-deleted record and two device.enrolled for two adds of one new device at once, the second reenrolled", async () => { + const harness = await build(); + + const results = await Promise.all([ + harness.core.enrollment.add("ios", UDID), + harness.core.enrollment.add("ios", ALIAS), + ]); + + expect(live(harness)).toHaveLength(1); + expect(results.map((result) => result.reenrolled)).toEqual([false, true]); + expect(enrolledEvents(harness).map((entry) => entry.payload)).toMatchObject([ + { reenrolled: false }, + { reenrolled: true }, + ]); + }); + + it("makes a quarantined physical device ready with no quarantine fields and the same registry ID when it is re-enrolled", async () => { + const harness = await build(); + const { device } = await harness.core.enrollment.add("ios", UDID); + await quarantine(harness, device.id); + expect(live(harness)[0]).toMatchObject({ + quarantinedAt: expect.any(Number), + state: "quarantined", + }); + + await harness.core.enrollment.add("ios", UDID); + + const record = live(harness)[0]; + expect(record).toMatchObject({ id: device.id, state: "ready" }); + expect(record).not.toHaveProperty("quarantinedAt"); + expect(record).not.toHaveProperty("quarantineAttempts"); + expect(record).not.toHaveProperty("quarantineNextRetryAt"); + }); + + it("makes a quarantined physical device deleted when it is removed", async () => { + const harness = await build(); + const { device } = await harness.core.enrollment.add("ios", UDID); + await quarantine(harness, device.id); + + await harness.core.enrollment.remove(UDID); + + expect(physicalRecords(harness)[0]).toMatchObject({ id: device.id, state: "deleted" }); + expect(physicalRecords(harness)[0]).not.toHaveProperty("quarantinedAt"); + }); + + it("answers DEVICE_BUSY leased to add and remove of a leased device, and changes nothing", async () => { + const harness = await build(); + const { device } = await harness.core.enrollment.add("ios", UDID); + await harness.registry.createLease({ + deviceId: device.id, + ownerId: "agent", + requesterId: "agent", + ttlDeadline: 61_000, + ttlMs: 60_000, + }); + const before = JSON.stringify(harness.registry.snapshot.devices); + + await expect(harness.core.enrollment.add("ios", UDID)).rejects.toMatchObject({ + name: "DeviceBusyError", + reason: "leased", + }); + await expect(harness.core.enrollment.remove(UDID)).rejects.toMatchObject({ + name: "DeviceBusyError", + reason: "leased", + }); + + expect(JSON.stringify(harness.registry.snapshot.devices)).toBe(before); + }); + + it("answers DEVICE_BUSY reclaiming to add and remove of a reclaiming device, and of a ready one whose reclaim claim is held", async () => { + const harness = await build(); + const { device } = await harness.core.enrollment.add("ios", UDID); + const claim = harness.core.claims.tryClaim(device.id, "reclaim"); + expect(claim).toBeDefined(); + for (const attempt of [ + () => harness.core.enrollment.add("ios", UDID), + () => harness.core.enrollment.remove(UDID), + ]) { + await expect(attempt()).rejects.toMatchObject({ + name: "DeviceBusyError", + reason: "reclaiming", + }); + } + claim?.release(); + + const lease = await harness.registry.createLease({ + deviceId: device.id, + ownerId: "agent", + requesterId: "agent", + ttlDeadline: 61_000, + ttlMs: 60_000, + }); + await harness.registry.beginRelease(lease.id); + expect(live(harness)[0]?.state).toBe("reclaiming"); + for (const attempt of [ + () => harness.core.enrollment.add("ios", UDID), + () => harness.core.enrollment.remove(UDID), + ]) { + await expect(attempt()).rejects.toMatchObject({ + name: "DeviceBusyError", + reason: "reclaiming", + }); + } + }); + + it("answers DEVICE_BUSY busy to another add and to a remove while an add waits on its second inspection", async () => { + const harness = await build(); + await harness.core.enrollment.add("ios", UDID); + // The second `add` reads once before it takes the claim, and again under it: hold that read. + const held = holdInspection(harness.driver, 2); + const waiting = harness.core.enrollment.add("ios", UDID); + await vi.waitFor(() => expect(held.reached).toHaveBeenCalled()); + + await expect(harness.core.enrollment.add("ios", ALIAS)).rejects.toMatchObject({ + name: "DeviceBusyError", + reason: "busy", + }); + await expect(harness.core.enrollment.remove(UDID)).rejects.toMatchObject({ + name: "DeviceBusyError", + reason: "busy", + }); + held.release(); + await expect(waiting).resolves.toMatchObject({ reenrolled: true }); + }); + + it.each([ + ["is refused", "refuse"], + ["throws", "throw"], + ] as const)( + "leaves the record as it was when the second inspection %s, and a remove right after it succeeds", + async (_case, how) => { + const harness = await build(); + await harness.core.enrollment.add("ios", UDID); + const before = JSON.stringify(live(harness)); + const original = harness.driver.inspectPhysical.bind(harness.driver); + let calls = 0; + vi.spyOn(harness.driver, "inspectPhysical").mockImplementation(async (id) => { + calls += 1; + if (calls < 2) return original(id); + if (how === "throw") throw new Error("usbmuxd went away"); + return { enrollable: false, message: "locked: unlock it and try again", reason: "locked" }; + }); + + const failed = harness.core.enrollment.add("ios", UDID); + + await expect(failed).rejects.toMatchObject( + how === "throw" + ? { name: "PhysicalInspectionError" } + : { canonicalId: UDID, name: "DeviceNotEnrollableError", reason: "locked" }, + ); + expect(JSON.stringify(live(harness))).toBe(before); + await expect(harness.core.enrollment.remove(UDID)).resolves.toMatchObject({ + state: "deleted", + }); + }, + ); + + it("answers INTERNAL and writes nothing when the first inspection throws", async () => { + const harness = await build(); + harness.driver.failOn("inspectPhysical", 1, new Error("usbmuxd went away")); + + await expect(harness.core.enrollment.add("ios", UDID)).rejects.toMatchObject({ + name: "PhysicalInspectionError", + }); + + expect(physicalRecords(harness)).toEqual([]); + expect(enrolledEvents(harness)).toEqual([]); + }); + + it("answers INTERNAL and writes nothing when the second inspection names another canonical ID, and a remove right after it succeeds", async () => { + const harness = await build(); + await harness.core.enrollment.add("ios", UDID); + const before = JSON.stringify(live(harness)); + const original = harness.driver.inspectPhysical.bind(harness.driver); + let calls = 0; + vi.spyOn(harness.driver, "inspectPhysical").mockImplementation(async (id) => { + calls += 1; + const read = await original(id); + return calls < 2 || !read.enrollable ? read : { ...read, driverDeviceId: "SOMEONE-ELSE" }; + }); + + await expect(harness.core.enrollment.add("ios", UDID)).rejects.toMatchObject({ + name: "PhysicalInspectionError", + }); + + expect(JSON.stringify(live(harness))).toBe(before); + expect(enrolledEvents(harness)).toHaveLength(1); + await expect(harness.core.enrollment.remove(UDID)).resolves.toMatchObject({ state: "deleted" }); + }); + + it("re-enrolls a second time after a re-enrollment succeeded, and a remove right after that succeeds", async () => { + const harness = await build(); + await harness.core.enrollment.add("ios", UDID); + await harness.core.enrollment.add("ios", UDID); + + await expect(harness.core.enrollment.add("ios", ALIAS)).resolves.toMatchObject({ + reenrolled: true, + }); + await expect(harness.core.enrollment.remove(UDID)).resolves.toMatchObject({ state: "deleted" }); + + expect(live(harness)).toEqual([]); + expect(enrolledEvents(harness)).toHaveLength(3); + }); + + it("refuses a Simlock simulator by its driverDeviceId or address as virtual-device, before any driver call", async () => { + const harness = await build(); + const provisioned = await harness.driver.provision({ + model: "iPhone 16", + osVersion: "1", + physical: false, + platform: "ios", + }); + const registered = await harness.registry.registerDevice({ + driverData: provisioned.driverData, + driverDeviceId: provisioned.deviceId, + provisionDuration: 0, + spec: { model: "iPhone 16", osVersion: "1", physical: false, platform: "ios" }, + }); + await harness.registry.transitionDevice( + registered.id, + "ready", + { + event: "device.ready", + payload: { bootDuration: 0, deviceId: registered.id }, + }, + { address: "emulator-5554" }, + ); + const asked = vi.spyOn(harness.driver, "inspectPhysical"); + + for (const id of [provisioned.deviceId, "emulator-5554"]) { + await expect(harness.core.enrollment.add("ios", id)).rejects.toMatchObject({ + id, + name: "DeviceNotEnrollableError", + reason: "virtual-device", + }); + } + + expect(asked).not.toHaveBeenCalled(); + }); + + it("answers enrolled-on-other-platform with the canonical ID when the device is enrolled on the other platform", async () => { + const harness = await build(); + const android = new FakeDriver({ + clock: harness.clock, + physicalDevices: [{ ids: [UDID] }], + platform: "android", + }); + const second = createCore({ + clock: harness.clock, + config: await loadConfig({ + filesystem: new MemoryFilesystem(), + systemStats: new FakeSystemStats({ cpuCount: 8, totalRamBytes: 32 * 1024 ** 3 }), + }), + drivers: [harness.driver, android], + eventBus: harness.eventBus, + registry: harness.registry, + systemStats: new FakeSystemStats({ cpuCount: 8, totalRamBytes: 32 * 1024 ** 3 }), + ...testComponentWiring({ + clock: harness.clock, + drivers: [harness.driver, android], + eventBus: harness.eventBus, + registry: harness.registry, + }), + }); + await second.enrollment.add("ios", UDID); + + await expect(second.enrollment.add("android", UDID)).rejects.toMatchObject({ + canonicalId: UDID, + name: "DeviceNotEnrollableError", + reason: "enrolled-on-other-platform", + }); + expect(live(harness)).toHaveLength(1); + }); +}); diff --git a/src/core/quarantine-coordinator.test.ts b/src/core/quarantine-coordinator.test.ts index e04767cdd..87bf5a6e7 100644 --- a/src/core/quarantine-coordinator.test.ts +++ b/src/core/quarantine-coordinator.test.ts @@ -739,3 +739,111 @@ describe("QuarantineCoordinator for a physical device (ADR 0022 §4)", () => { expect(harness.registry.snapshot.devices[0]?.state).toBe("quarantined"); }); }); + +describe("QuarantineCoordinator retry claim (ADR 0022 §2)", () => { + async function stalled(options: Parameters[0] = {}) { + const clock = options.clock ?? new FakeClock(1_000); + const harness = await createHarness({ ...options, clock }); + await harness.coordinator.enterFromStalledTransition(harness.target.id); + return harness; + } + + it("makes no driver call and keeps its attempt count when its claim is held, then runs one retryBackoffMs later", async () => { + const harness = await stalled(); + const held = harness.claims.tryClaim(harness.target.id, "enrollment"); + + harness.clock.advance(retryConfig.retryBackoffMs); + await flush(); + + expect(harness.driver.calls.filter((call) => call.operation === "reclaim")).toEqual([]); + expect(harness.registry.snapshot.virtualDevices[0]).toMatchObject({ + quarantineAttempts: 0, + state: "quarantined", + }); + held?.release(); + harness.clock.advance(retryConfig.retryBackoffMs - 1); + await flush(); + expect(harness.driver.calls.filter((call) => call.operation === "reclaim")).toEqual([]); + harness.clock.advance(1); + await flush(); + + expect(harness.driver.calls.filter((call) => call.operation === "reclaim")).toHaveLength(1); + expect(eventsOf(harness.bus).at(-1)).toMatchObject({ + event: "device.quarantine-recovered", + payload: { attempts: 1 }, + }); + }); + + it.each([ + ["succeeds", false], + ["throws", true], + ] as const)( + "holds the reclaim claim while the driver's reclaim runs, and frees it after the call %s", + async (_case, throws) => { + const clock = new FakeClock(1_000); + const driver = new FakeDriver({ clock, platform: "ios", reclaimResult: "ready" }); + if (throws) driver.failOn("reclaim", 1, new Error("still stuck")); + const harness = await stalled({ clock, driver }); + const original = driver.reclaim.bind(driver); + const duringCall: unknown[] = []; + vi.spyOn(driver, "reclaim").mockImplementation(async (device, options) => { + duringCall.push(harness.claims.operationFor(harness.target.id)); + return original(device, options); + }); + + harness.clock.advance(retryConfig.retryBackoffMs); + await flush(); + + expect(duringCall).toEqual(["reclaim"]); + expect(harness.claims.isClaimed(harness.target.id)).toBe(false); + expect(harness.registry.snapshot.virtualDevices[0]?.state).toBe( + throws ? "quarantined" : "ready", + ); + }, + ); + + it.each([ + ["recovers the device", {}, 1], + [ + "deletes a spent fresh device", + { target: { lastLeaseEndedAt: 900, leaseIdentity: "fresh" } as const }, + 1, + ], + ] as const)( + "has freed the claim before it emits and before it wakes availability when a retry %s", + async (_case, options, wakes) => { + const harness = await stalled(options); + const claimedAt: boolean[] = []; + const sees = () => claimedAt.push(harness.claims.isClaimed(harness.target.id)); + harness.bus.subscribe("device.quarantine-recovered", sees); + harness.notifyAvailability.mockImplementation(sees); + + harness.clock.advance(retryConfig.retryBackoffMs); + await flush(); + + expect(harness.notifyAvailability).toHaveBeenCalledTimes(wakes); + expect(claimedAt.length).toBeGreaterThan(0); + expect(claimedAt.every((claimed) => !claimed)).toBe(true); + }, + ); + + it("has freed the claim before it emits and before it wakes availability when a retry gives up", async () => { + const clock = new FakeClock(1_000); + const driver = new FakeDriver({ clock, platform: "ios" }); + driver.failOn("reclaim", 1, new Error("still stuck")); + driver.failOn("reclaim", 2, new Error("still stuck")); + const harness = await stalled({ clock, driver }); + const claimedAt: boolean[] = []; + const sees = () => claimedAt.push(harness.claims.isClaimed(harness.target.id)); + harness.bus.subscribe("device.quarantine-abandoned", sees); + harness.notifyAvailability.mockImplementation(sees); + + harness.clock.advance(retryConfig.retryBackoffMs); + await flush(); + harness.clock.advance(2_000); + await flush(); + + expect(eventsOf(harness.bus).at(-1)?.event).toBe("device.quarantine-abandoned"); + expect(claimedAt).toEqual([false, false]); + }); +}); diff --git a/src/core/registry.test.ts b/src/core/registry.test.ts index 9e46fa2bd..7249bcabd 100644 --- a/src/core/registry.test.ts +++ b/src/core/registry.test.ts @@ -863,7 +863,7 @@ describe("Registry", () => { const deleted = await registry.deleteQuarantined(device.id, "lease-end"); expect(deleted.state).toBe("deleted"); - expect(events).toEqual([{ deviceId: device.id, initiator: "lease-end" }]); + expect(events).toEqual([{ deviceId: device.id, initiator: "lease-end", physical: false }]); }); it("rejects mutations for a device that is not registered", async () => { @@ -2685,3 +2685,144 @@ describe("Registry: physical devices (ADR 0022 §1)", () => { }); }); }); + +describe("Registry: device.deleted carries physical, and enrollment writes (ADR 0022 §2)", () => { + async function load() { + const clock = new FakeClock(1_000); + const bus = new EventBus(clock); + let next = 1; + const registry = await Registry.load({ + clock, + eventBus: bus, + filesystem: new MemoryFilesystem(), + idGenerator: { generate: () => `r${next++}` }, + statePath, + }); + const deleted: unknown[] = []; + bus.subscribe("device.deleted", (envelope) => deleted.push(envelope.payload)); + return { bus, deleted, registry }; + } + + async function readyVirtual(registry: Registry, driverDeviceId: string) { + const device = await registry.registerDevice({ + driverData: {}, + driverDeviceId, + provisionDuration: 0, + spec, + }); + await registry.transitionDevice(device.id, "ready", { + event: "device.ready", + payload: { bootDuration: 0, deviceId: device.id }, + }); + return device; + } + + const input = { + apps: ["com.example.one"], + class: "phone", + driverDeviceId: "00008110-001A2B3C4D5E", + model: "iPhone 15 Pro", + osVersion: "18.4", + platform: "ios", + } as const; + + it("carries physical false from deleteQuarantined, markDeviceMissing, endLeaseAndMarkDeviceMissing and a transitionDevice of a virtual record", async () => { + const { deleted, registry } = await load(); + const quarantined = await readyVirtual(registry, "d1"); + const lease = await registry.createLease({ + deviceId: quarantined.id, + ownerId: "a", + requesterId: "a", + ttlDeadline: 9_000, + ttlMs: 8_000, + }); + await registry.beginRelease(lease.id); + await registry.enterQuarantine(quarantined.id, 5_000); + await registry.deleteQuarantined(quarantined.id, "lease-end"); + const missing = await readyVirtual(registry, "d2"); + await registry.markDeviceMissing(missing.id, "doctor"); + const leased = await readyVirtual(registry, "d3"); + const held = await registry.createLease({ + deviceId: leased.id, + ownerId: "a", + requesterId: "a", + ttlDeadline: 9_000, + ttlMs: 8_000, + }); + await registry.endLeaseAndMarkDeviceMissing(held.id, "doctor", () => {}); + const plain = await readyVirtual(registry, "d4"); + await registry.transitionDevice(plain.id, "shutdown", { + event: "device.shutdown", + payload: { deviceId: plain.id, initiator: "idle" }, + }); + await registry.transitionDevice(plain.id, "deleted", { + event: "device.deleted", + payload: { deviceId: plain.id, initiator: "idle" }, + }); + + expect(deleted).toEqual([ + { deviceId: quarantined.id, initiator: "lease-end", physical: false }, + { deviceId: missing.id, initiator: "doctor", physical: false }, + { deviceId: leased.id, initiator: "doctor", physical: false }, + { deviceId: plain.id, initiator: "idle", physical: false }, + ]); + }); + + it("carries physical true and initiator operator from unenroll, after the commit", async () => { + const { deleted, registry } = await load(); + const device = await registry.enrollPhysical(input); + + await registry.unenrollPhysical(device.id); + + expect(deleted).toEqual([{ deviceId: device.id, initiator: "operator", physical: true }]); + expect(registry.snapshot.devices[0]).toMatchObject({ id: device.id, state: "deleted" }); + }); + + it("emits device.enrolled after the commit, reenrolled false for a new record and true for a rewrite", async () => { + const { bus, registry } = await load(); + const seen: unknown[] = []; + bus.subscribe("device.enrolled", (envelope) => + seen.push({ payload: envelope.payload, stored: registry.snapshot.devices.length }), + ); + + const device = await registry.enrollPhysical(input); + await registry.reenrollPhysical(device.id, { ...input, apps: ["a", "b"], model: "iPhone 16" }); + + expect(seen).toEqual([ + { + payload: { + appCount: 1, + class: "phone", + deviceId: device.id, + driverDeviceId: input.driverDeviceId, + model: "iPhone 15 Pro", + osVersion: "18.4", + platform: "ios", + reenrolled: false, + }, + stored: 1, + }, + { + payload: expect.objectContaining({ appCount: 2, model: "iPhone 16", reenrolled: true }), + stored: 1, + }, + ]); + }); + + it("refuses to re-enrol or unenrol a leased physical record, and a virtual one", async () => { + const { registry } = await load(); + const device = await registry.enrollPhysical(input); + await registry.createLease({ + deviceId: device.id, + ownerId: "a", + requesterId: "a", + ttlDeadline: 9_000, + ttlMs: 8_000, + }); + const virtual = await readyVirtual(registry, "d1"); + + await expect(registry.reenrollPhysical(device.id, input)).rejects.toThrow(RegistryEventError); + await expect(registry.unenrollPhysical(device.id)).rejects.toThrow(RegistryEventError); + await expect(registry.unenrollPhysical(virtual.id)).rejects.toThrow(RegistryEventError); + }); +}); diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index 393a61f04..8224b3964 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -1557,6 +1557,27 @@ describe("AndroidDriver", () => { expect(reality.processes).toEqual([expect.objectContaining({ deviceId: "simlock_running" })]); }); + it("inspectPhysical refuses every ID as not-connected, with a message that it cannot read physical devices yet, and starts no process", async () => { + const filesystem = await androidFilesystem(); + const runner = new ScriptedProcessRunner([]); + const driver = await createDriver(filesystem, runner); + const callsBefore = runner.calls.length; + + const reads = await Promise.all([ + driver.inspectPhysical("R58M123ABC"), + driver.inspectPhysical("emulator-5554"), + ]); + + for (const read of reads) { + expect(read).toEqual({ + enrollable: false, + message: "this driver cannot read physical devices yet", + reason: "not-connected", + }); + } + expect(runner.calls).toHaveLength(callsBefore); + }); + it("listManaged passes a 30-second timeout to adb devices", async () => { const filesystem = await androidFilesystem(); await filesystem.mkdirp(`${avdDirectory}/simlock_idle.avd`); diff --git a/src/drivers/ios/index.test.ts b/src/drivers/ios/index.test.ts index f6a9ed65a..4d5ceca66 100644 --- a/src/drivers/ios/index.test.ts +++ b/src/drivers/ios/index.test.ts @@ -1283,6 +1283,26 @@ describe("IosSimctlDriver", () => { ]); }); + it("refuses every ID as not-connected, with a message that it cannot read physical devices yet, and starts no process", async () => { + const runner = new ScriptedProcessRunner([]); + const driver = await createDriver(runner); + const callsBefore = runner.calls.length; + + const reads = await Promise.all([ + driver.inspectPhysical("00008110-001A2B3C4D5E"), + driver.inspectPhysical("not-an-id"), + ]); + + for (const read of reads) { + expect(read).toEqual({ + enrollable: false, + message: "this driver cannot read physical devices yet", + reason: "not-connected", + }); + } + expect(runner.calls).toHaveLength(callsBefore); + }); + it("populates processes from booted managed devices only", async () => { const runner = new ScriptedProcessRunner([ { match: listDevicesInvocation, result: { code: 0, stderr: "", stdout: listDevicesFixture } }, diff --git a/src/leasing/create-leasing.test.ts b/src/leasing/create-leasing.test.ts index ca2aff5c6..abe1023c6 100644 --- a/src/leasing/create-leasing.test.ts +++ b/src/leasing/create-leasing.test.ts @@ -4121,3 +4121,54 @@ describe("createLeasing: a gateway dispatch is a probe (ADR 0021)", () => { }); }); }); + +describe("createLeasing after a quarantine retry (ADR 0022 §2)", () => { + it("grants a request that waits at capacity the quarantined simulator, with no boot, once its retry succeeds", async () => { + const clock = new FakeClock(1_000); + const driver = new FakeDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }); + driver.failOn("reclaim", 1, new DriverCrashError("purge exploded")); + const harness = await createHarness({ driver }); + const first = await harness.engine.request(request, { ownerId: "first", requesterId: "first" }); + await harness.engine.release(first.lease.id, "explicit"); + await harness.engine.settle(); + expect(harness.registry.snapshot.devices[0]?.state).toBe("quarantined"); + const bootsBefore = driver.calls.filter((call) => call.operation === "makeReady").length; + const waiting = harness.engine.request(request, { ownerId: "second", requesterId: "second" }); + await flush(); + + harness.clock.advance(config().warmPool.quarantine.retryBackoffMs); + await flush(); + + const grant = await waiting; + expect(grant.device.id).toBe(first.device.id); + expect(driver.calls.filter((call) => call.operation === "makeReady")).toHaveLength(bootsBefore); + expect(driver.calls.filter((call) => call.operation === "provision")).toHaveLength(1); + }); + + it("reports a warm target of one for the quarantined simulator's spec as ready 1 in the pass the retry's device.quarantine-recovered starts", async () => { + const clock = new FakeClock(1_000); + const driver = new FakeDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }); + driver.failOn("reclaim", 1, new DriverCrashError("purge exploded")); + const harness = await createHarness({ + driver, + warmPool: { targets: [{ count: 1, model: "iPhone 16", osVersion: "26.5", platform: "ios" }] }, + }); + await harness.engine.convergeRunningCapacity(); + harness.bus.emit("daemon.started", { configSnapshot: {}, version: "test" }, "test"); + await harness.engine.settle(); + const first = await harness.engine.request(request, { ownerId: "first", requesterId: "first" }); + await harness.engine.release(first.lease.id, "explicit"); + await harness.engine.settle(); + expect(harness.registry.snapshot.devices[0]?.state).toBe("quarantined"); + expect(harness.engine.core.warmPoolReader.figures().targets[0]?.ready).toBe(0); + + harness.clock.advance(config().warmPool.quarantine.retryBackoffMs); + await flush(); + await harness.engine.settle(); + + expect(harness.registry.snapshot.devices[0]?.state).toBe("ready"); + expect(harness.engine.core.warmPoolReader.figures().targets).toMatchObject([ + { count: 1, ready: 1 }, + ]); + }); +}); diff --git a/ui/src/views/worker-detail.test.tsx b/ui/src/views/worker-detail.test.tsx index 375308e17..a1f881cd8 100644 --- a/ui/src/views/worker-detail.test.tsx +++ b/ui/src/views/worker-detail.test.tsx @@ -94,6 +94,40 @@ describe("the page of a worker that has finished starting", () => { expect(shown).not.toContain("appear once startup finishes"); }); + it("marks a simulator Virtual and an enrolled device Physical, and shows a dash for the physical device's mode and image tag", () => { + const shown = detail({ + ...RUNNING, + devices: [ + { + physical: false, + id: "dev_sim", + mode: "full", + servesDefaultMode: true, + spec: { + imageTag: "google_apis", + model: "Pixel 9", + osVersion: "35", + physical: false, + platform: "android", + }, + state: "ready", + }, + { + class: "phone", + driverDeviceId: "00008110-001A2B3C4D5E", + id: "dev_phys", + physical: true, + servesDefaultMode: true, + spec: { model: "iPhone 15 Pro", osVersion: "18.4", physical: true, platform: "ios" }, + state: "ready", + }, + ], + }); + + expect(shown).toContain("Android Virtual Pixel 9 35 full google_apis"); + expect(shown).toContain("iOS Physical iPhone 15 Pro 18.4 — —"); + }); + it("says so where a worker's catalog and devices were never read", () => { const { catalog: _catalog, devices: _devices, ...unread } = RUNNING; From a49315944ccde7b5183a8d010f55697de7409a16 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 10:02:08 +0200 Subject: [PATCH 05/22] chore: protocol 24 across tests and docs; device.deleted carries physical in the fixtures (#437) 9 failing -> 0 failing --- docs/internal/ARCHITECTURE.md | 6 +++--- src/core/reclaim-coordinator.test.ts | 2 +- src/daemon-protocol/index.test.ts | 4 ++-- src/daemon/server.test.ts | 4 ++-- src/gateway/service.test.ts | 4 ++-- src/leasing/lease-reconciler.test.ts | 1 + ui/src/views/workers.test.tsx | 6 ++++-- 7 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/internal/ARCHITECTURE.md b/docs/internal/ARCHITECTURE.md index 97e741801..e653f89b9 100644 --- a/docs/internal/ARCHITECTURE.md +++ b/docs/internal/ARCHITECTURE.md @@ -274,8 +274,8 @@ event envelope an `id`, taking it to 10, and ADR 0009 makes `atRamBudget` a required capacity field, taking it to 11, and ADR 0015 §3 makes `modelClasses` a required catalog field, taking it to 12, and ADR 0015 §4 makes `classDefaults` one too, taking it to 13, and a device's `servesDefaultMode`, required too, takes it to 16 (ADR 0015 §1 and §2 took it to 14 and 15), and `config.get`'s required `warmPool.reserveRunning` takes it to 17, and a starting daemon's `status.get`, which -answers `daemon` and `host` only, takes it to 18, and `config.get`'s required `warmPool.targets` and `warmPool.maxConcurrentBoots` take it to 19, and `status.get`'s `warmPool` block takes it to 20, and a lease request's optional `leaseId` takes it to 21, and its optional `fleetRequestId` (ADR 0021) takes it to 22, and a device's required `physical` (ADR 0022) takes it to 23. So the range both -sides advertise is `{min: 23, max: 23}`, an older client and a current daemon simply +answers `daemon` and `host` only, takes it to 18, and `config.get`'s required `warmPool.targets` and `warmPool.maxConcurrentBoots` take it to 19, and `status.get`'s `warmPool` block takes it to 20, and a lease request's optional `leaseId` takes it to 21, and its optional `fleetRequestId` (ADR 0021) takes it to 22, and a device's required `physical` (ADR 0022) takes it to 23, and the operations `device.add` and `device.remove` (ADR 0022 §2), with a physical device's `driverDeviceId` on its status entry, take it to 24. So the range both +sides advertise is `{min: 24, max: 24}`, an older client and a current daemon simply do not overlap, and `hello` fails with `PROTOCOL_VERSION_UNSUPPORTED` naming both ranges. The same negotiation runs over a worker's uplink, which is why a worker older than this shows up in a gateway's views as `incompatible` @@ -966,7 +966,7 @@ emits its own facts — `worker.connected`, `worker.disconnected`, ADR 0014 to `{min: 10, max: 10}`, because every event envelope has an `id`, and ADR 0009 to `{min: 11, max: 11}`, because `atRamBudget` is required, and ADR 0015 to `{min: 12, max: 12}`, because the catalog's `modelClasses` is required, then - to `{min: 13, max: 13}`, because its `classDefaults` is, and ADR 0009 to `{min: 16, max: 16}`, because a device's `servesDefaultMode` is, then to `{min: 17, max: 17}`, because `config.get`'s `warmPool.reserveRunning` is, and a starting `status.get` to `{min: 18, max: 18}`, because its `devices`, `leases`, `capacity` and `queueDepth` are optional, then to `{min: 19, max: 19}`, because `config.get`'s `warmPool.targets` and `warmPool.maxConcurrentBoots` are required, and to `{min: 20, max: 20}`, because `status.get` gains `warmPool`, and to `{min: 21, max: 21}`, because a lease request may carry `leaseId`, and to `{min: 22, max: 22}`, because it may carry `fleetRequestId`, and to `{min: 23, max: 23}`, because every device carries `physical`; a + to `{min: 13, max: 13}`, because its `classDefaults` is, and ADR 0009 to `{min: 16, max: 16}`, because a device's `servesDefaultMode` is, then to `{min: 17, max: 17}`, because `config.get`'s `warmPool.reserveRunning` is, and a starting `status.get` to `{min: 18, max: 18}`, because its `devices`, `leases`, `capacity` and `queueDepth` are optional, then to `{min: 19, max: 19}`, because `config.get`'s `warmPool.targets` and `warmPool.maxConcurrentBoots` are required, and to `{min: 20, max: 20}`, because `status.get` gains `warmPool`, and to `{min: 21, max: 21}`, because a lease request may carry `leaseId`, and to `{min: 22, max: 22}`, because it may carry `fleetRequestId`, and to `{min: 23, max: 23}`, because every device carries `physical`, and to `{min: 24, max: 24}`, because `device.add` and `device.remove` are operations and a physical device's status entry carries `driverDeviceId`; a worker on an older version is `incompatible` the same way. That is the ordinary upgrade path, not a failure mode: upgrade the worker. An incompatible worker is marked `incompatible` in its view with both ranges shown and is never dispatched to, and it is not diff --git a/src/core/reclaim-coordinator.test.ts b/src/core/reclaim-coordinator.test.ts index 6f59c9ece..6f279398b 100644 --- a/src/core/reclaim-coordinator.test.ts +++ b/src/core/reclaim-coordinator.test.ts @@ -425,7 +425,7 @@ describe("ReclaimCoordinator", () => { ).toEqual([ { event: "device.deleted", - payload: { deviceId: harness.target.id, initiator: "lease-end" }, + payload: { deviceId: harness.target.id, initiator: "lease-end", physical: false }, }, ]); expect(harness.quarantined).toEqual([]); diff --git a/src/daemon-protocol/index.test.ts b/src/daemon-protocol/index.test.ts index 5612c0527..f4d9da730 100644 --- a/src/daemon-protocol/index.test.ts +++ b/src/daemon-protocol/index.test.ts @@ -18,8 +18,8 @@ describe("daemon protocol", () => { // lets a lease request choose it, taking it to 8, each the same way. ADR 0010 adds // `component.install` and its `component-progress` push, which a gateway relays to its // workers, taking it to 9. ADR 0014 gives every event envelope an `id`, taking it to 10. ADR 0009 §7 makes `atRamBudget` a - // required field of each platform's `status.get` capacity, taking it to 11, then ADR 0015 §3 made `modelClasses` a required field of each platform catalog, taking it to 12, then ADR 0015 §4 made `classDefaults` a required field of each platform catalog too, taking it to 13, then ADR 0015 §1 made a lease request's `model` optional beside a new `class`, taking it to 14, then ADR 0015 §2 let `osVersion` be a range, taking it to 15, then ADR 0009 §6 made `servesDefaultMode` a required field of each device in `status.get`, taking it to 16, then `warmPool.reserveRunning` became a required field of `config.get`, taking it to 17, then a starting `status.get` made `devices`, `leases`, `capacity` and `queueDepth` optional, taking it to 18, then `warmPool.targets` and `warmPool.maxConcurrentBoots` became required fields of `config.get`, taking it to 19, then `status.get` gained `warmPool`, taking it to 20, then `lease.request` gained `leaseId` and every lease record `idChosenByRequester`, taking it to 21, then `lease.request` gained `fleetRequestId`, which a gateway sets on every dispatch, taking it to 22, then every device a worker reports gained `physical`, taking it to 23. - expect(DAEMON_PROTOCOL_VERSION).toBe(23); + // required field of each platform's `status.get` capacity, taking it to 11, then ADR 0015 §3 made `modelClasses` a required field of each platform catalog, taking it to 12, then ADR 0015 §4 made `classDefaults` a required field of each platform catalog too, taking it to 13, then ADR 0015 §1 made a lease request's `model` optional beside a new `class`, taking it to 14, then ADR 0015 §2 let `osVersion` be a range, taking it to 15, then ADR 0009 §6 made `servesDefaultMode` a required field of each device in `status.get`, taking it to 16, then `warmPool.reserveRunning` became a required field of `config.get`, taking it to 17, then a starting `status.get` made `devices`, `leases`, `capacity` and `queueDepth` optional, taking it to 18, then `warmPool.targets` and `warmPool.maxConcurrentBoots` became required fields of `config.get`, taking it to 19, then `status.get` gained `warmPool`, taking it to 20, then `lease.request` gained `leaseId` and every lease record `idChosenByRequester`, taking it to 21, then `lease.request` gained `fleetRequestId`, which a gateway sets on every dispatch, taking it to 22, then every device a worker reports gained `physical`, taking it to 23, then `device.add` and `device.remove` joined the operations and a physical device's status entry gained `driverDeviceId`, taking it to 24. + expect(DAEMON_PROTOCOL_VERSION).toBe(24); expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n'); }); diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index a67954677..b22f106c3 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -185,7 +185,7 @@ describe("DaemonServer", () => { [20, "cannot send lease.request's leaseId or read a lease's idChosenByRequester"], [21, "cannot send lease.request's fleetRequestId"], ])( - "answers PROTOCOL_VERSION_UNSUPPORTED, naming protocol 23, to a client on protocol %i, which %s", + "answers PROTOCOL_VERSION_UNSUPPORTED, naming protocol 24, to a client on protocol %i, which %s", async (version) => { const harness = await createHarness(); const previous = await createClient(harness.socketPath); @@ -195,7 +195,7 @@ describe("DaemonServer", () => { ).resolves.toMatchObject({ error: { code: "PROTOCOL_VERSION_UNSUPPORTED", - details: { client: { min: version, max: version }, daemon: { min: 23, max: 23 } }, + details: { client: { min: version, max: version }, daemon: { min: 24, max: 24 } }, }, ok: false, }); diff --git a/src/gateway/service.test.ts b/src/gateway/service.test.ts index c35394b55..bba13035f 100644 --- a/src/gateway/service.test.ts +++ b/src/gateway/service.test.ts @@ -1137,12 +1137,12 @@ describe("GatewayService", () => { await harness.service.stop(); }); - it("marks a worker one protocol version behind the gateway incompatible (ADR 0022 §1: its devices carry no `physical`, which the gateway schema requires)", async () => { + it("marks a worker one protocol version behind the gateway incompatible (ADR 0022 §2: it does not know `device.add` or `device.remove`, and reports no `driverDeviceId`)", async () => { const previous = { min: PROTOCOL_VERSION_RANGE.max - 1, max: PROTOCOL_VERSION_RANGE.max - 1, }; - expect(PROTOCOL_VERSION_RANGE.max).toBe(23); + expect(PROTOCOL_VERSION_RANGE.max).toBe(24); expect(negotiateProtocolVersion(PROTOCOL_VERSION_RANGE, previous)).toBeUndefined(); const harness = fleet(); await harness.service.start(); diff --git a/src/leasing/lease-reconciler.test.ts b/src/leasing/lease-reconciler.test.ts index 959e090c5..13a06447b 100644 --- a/src/leasing/lease-reconciler.test.ts +++ b/src/leasing/lease-reconciler.test.ts @@ -222,6 +222,7 @@ describe("LeaseReconciler", () => { expect(events.find((entry) => entry.event === "device.deleted")?.payload).toEqual({ deviceId: lease.deviceId, initiator: "doctor", + physical: false, }); expect(harness.reclaims).toEqual([]); }); diff --git a/ui/src/views/workers.test.tsx b/ui/src/views/workers.test.tsx index 9f5561f15..ad82688cd 100644 --- a/ui/src/views/workers.test.tsx +++ b/ui/src/views/workers.test.tsx @@ -99,7 +99,7 @@ describe("the workers views", () => { expect(shown).toContain("dev_ready ready —"); }); - it("a physical device is a row of the worker's Devices table with an empty Mode cell, and counts in the Workers view's device total", () => { + it("a physical device is a row of the worker's Devices table typed Physical with a dash for Mode and Image tag, and counts in the Workers view's device total", () => { const physical = { class: "phone" as const, id: "dev_p", @@ -119,7 +119,9 @@ describe("the workers views", () => { expect(text(html)).toContain("dev_p ready"); expect(html).toContain('iPhone 15 Pro'); - expect(html).toMatch(/]*><\/td>/); + expect(html).toContain('Physical'); + expect(html).toContain('—'); + expect(html).toContain('—'); expect(workersStats([view]).find((stat) => stat.label === "Devices")?.caption).toBe( "running of 1", ); From 66e2d5789858ef324ce39b84f6820dc616ed6cea Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 10:02:26 +0200 Subject: [PATCH 06/22] test: a physical device's status entry keeps its canonical ID (#437) --- src/contract/schemas.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/contract/schemas.test.ts b/src/contract/schemas.test.ts index 6e068d767..d63b77087 100644 --- a/src/contract/schemas.test.ts +++ b/src/contract/schemas.test.ts @@ -206,6 +206,26 @@ describe("statusDeviceSchema's device projection", () => { }); }); + it("keeps a physical device's driverDeviceId, its canonical ID, and still strips its driver data", () => { + const device = statusDeviceSchema.parse({ + ...fullCoreShapedDevice(), + class: "phone", + driverDeviceId: "00008110-001A2B3C4D5E", + mode: undefined, + physical: true, + spec: { physical: true, platform: "ios", model: "iPhone 15 Pro", osVersion: "18.4" }, + state: "ready", + }); + + expect(device).toMatchObject({ + class: "phone", + driverDeviceId: "00008110-001A2B3C4D5E", + physical: true, + }); + expect(device).not.toHaveProperty("driverData"); + expect(device).not.toHaveProperty("address"); + }); + it("rejects a device object that is missing the fields status.get must keep", () => { expect(() => statusDeviceSchema.parse({ id: "device-1" })).toThrow(); }); From 702260fb4795b3af816050bc58927e51cdfc691a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 10:03:55 +0200 Subject: [PATCH 07/22] fix: fallow findings for enrollment: ports as interfaces, smaller CLI parser (#437) --- src/cli/index.ts | 82 ++++++++++++++++++++++----------- src/contract/errors.ts | 2 +- src/core/fake-driver.ts | 9 ++-- src/core/index.ts | 3 -- src/core/physical-enrollment.ts | 18 +++++--- src/core/registry.ts | 3 ++ src/drivers/android/index.ts | 1 - src/drivers/ios/index.ts | 1 - 8 files changed, 78 insertions(+), 41 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 8e56d12d5..df6ad5589 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1973,46 +1973,76 @@ async function runDevice( if (command !== "add" && command !== "remove") { throw new UsageError(withHelpHint(`Unknown device command: ${command}`)); } - const values = commandArgs(argv.slice(1), { + const parsed = parseDeviceArgs(command, argv.slice(1)); + if (parsed === "help") { + environment.stdout.write(DEVICE_USAGE); + return 0; + } + const client = await connectDaemonClient(environment, token); + try { + const { line, result } = + parsed.platform === undefined + ? await removeDevice(client, parsed.id) + : await addDevice(client, parsed.id, parsed.platform); + environment.stdout.write(`${parsed.json ? JSON.stringify(result) : line}\n`); + return 0; + } finally { + await client.close(); + } +} + +/** `device add`'s arguments: one ID and `--platform`; `device remove`'s: one ID. */ +function parseDeviceArgs( + command: "add" | "remove", + argv: readonly string[], +): + | { + readonly id: string; + readonly json: boolean; + readonly platform: "ios" | "android" | undefined; + } + | "help" { + const values = commandArgs(argv, { help: { type: "boolean", short: "h" }, json: { type: "boolean" }, ...(command === "add" ? { platform: { type: "string" as const } } : {}), }); - if (values.help) { - environment.stdout.write(DEVICE_USAGE); - return 0; - } + if (values.help) return "help"; const [id, ...extra] = values.positionals; if (id === undefined || extra.length > 0) { throw new UsageError(withHelpHint(`device ${command} takes exactly one `)); } - const platform = values.platform; - if (command === "add" && platform !== "ios" && platform !== "android") { + const json = values.json === true; + if (command === "remove") return { id, json, platform: undefined }; + if (values.platform !== "ios" && values.platform !== "android") { throw new UsageError( withHelpHint( "device add needs --platform ios or --platform android, to say which driver reads the device", ), ); } - const client = await connectDaemonClient(environment, token); - try { - if (command === "add") { - const result = await client.addDevice({ id, platform: platform as "ios" | "android" }); - environment.stdout.write( - values.json === true ? `${JSON.stringify(result)}\n` : `${describeEnrolled(result)}\n`, - ); - return 0; - } - const result = await client.removeDevice({ id }); - environment.stdout.write( - values.json === true - ? `${JSON.stringify(result)}\n` - : `Removed ${result.driverDeviceId}. It is no longer enrolled; nothing on the device changed.\n`, - ); - return 0; - } finally { - await client.close(); - } + return { id, json, platform: values.platform }; +} + +async function addDevice( + client: SimlockAdminClient, + id: string, + platform: "ios" | "android", +): Promise<{ readonly line: string; readonly result: unknown }> { + const result = await client.addDevice({ id, platform }); + return { line: describeEnrolled(result), result }; +} + +async function removeDevice( + client: SimlockAdminClient, + id: string, +): Promise<{ readonly line: string; readonly result: unknown }> { + const result = await client.removeDevice({ id }); + return { line: describeRemoved(result), result }; +} + +function describeRemoved(result: { readonly driverDeviceId: string }): string { + return `Removed ${result.driverDeviceId}. It is no longer enrolled; nothing on the device changed.`; } /** `Enrolled iPhone 15 Pro (iOS 18.4) as [ again], 12 user apps recorded.` */ diff --git a/src/contract/errors.ts b/src/contract/errors.ts index 233c681b3..7a0643dce 100644 --- a/src/contract/errors.ts +++ b/src/contract/errors.ts @@ -12,7 +12,7 @@ import type { DeviceClass, Platform } from "./schemas.js"; /** Why a device cannot be enrolled (ADR 0022 §2); `DEVICE_NOT_ENROLLABLE`'s `reason`. */ -export const DEVICE_NOT_ENROLLABLE_REASONS = [ +const DEVICE_NOT_ENROLLABLE_REASONS = [ "not-connected", "network-only", "untrusted", diff --git a/src/core/fake-driver.ts b/src/core/fake-driver.ts index 333810b1f..5de1f299d 100644 --- a/src/core/fake-driver.ts +++ b/src/core/fake-driver.ts @@ -213,7 +213,9 @@ export class FakeDriver implements Driver { /** Bumped on every `makeReady` boot -- mirrors a real driver reassigning a port per boot. */ readonly #bootCounts = new Map(); #managedReality: DriverReality | undefined; - #physicalDevices: readonly FakePhysicalDevice[]; + /** What `setPhysicalDevices` replaced, or the option when it never ran. */ + #physicalDevices: readonly FakePhysicalDevice[] | undefined; + readonly #scriptedPhysicalDevices: FakeDriverOptions["physicalDevices"]; constructor(options: FakeDriverOptions) { this.#availableOsVersions = new Set(options.availableOsVersions ?? ["latest"]); @@ -239,7 +241,7 @@ export class FakeDriver implements Driver { this.deviceRoot = options.deviceRoot ?? `/fake/${options.platform}`; this.#reclaimResult = options.reclaimResult ?? "ready"; this.#reclaimStrategy = options.reclaimStrategy ?? "wipe"; - this.#physicalDevices = options.physicalDevices ?? []; + this.#scriptedPhysicalDevices = options.physicalDevices; this.#install = options; this.#receipts = preinstalledReceipts(this.#availableOsVersions); } @@ -553,7 +555,8 @@ export class FakeDriver implements Driver { async inspectPhysical(id: string): Promise { await this.#beforeCall("inspectPhysical", id); - const device = this.#physicalDevices.find((candidate) => candidate.ids.includes(id)); + const scripted = this.#physicalDevices ?? this.#scriptedPhysicalDevices ?? []; + const device = scripted.find((candidate) => candidate.ids.includes(id)); if (device === undefined) { return { enrollable: false, message: `${id} is not connected`, reason: "not-connected" }; } diff --git a/src/core/index.ts b/src/core/index.ts index 8123533a1..22702aa97 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -58,7 +58,6 @@ export { type DriverDevice, type DriverEstimate, type DriverReality, - type DriverRefusalReason, type DriverRejection, type DriverToolVersion, InsufficientDiskSpaceError, @@ -127,9 +126,7 @@ export { DeviceBusyError, DeviceNotEnrollableError, PhysicalEnrollment, - PhysicalInspectionError, UnknownEnrolledDeviceError, - type PhysicalEnrollmentResult, } from "./physical-enrollment.js"; export { DeviceProvisioner } from "./device-provisioner.js"; export { diff --git a/src/core/physical-enrollment.ts b/src/core/physical-enrollment.ts index c08e4e025..2f5ca8c97 100644 --- a/src/core/physical-enrollment.ts +++ b/src/core/physical-enrollment.ts @@ -49,14 +49,14 @@ export class UnknownEnrolledDeviceError extends Error { } /** A driver's `inspectPhysical` threw, or named another device than the one asked for. */ -export class PhysicalInspectionError extends Error { +class PhysicalInspectionError extends Error { constructor(message: string) { super(message); this.name = "PhysicalInspectionError"; } } -export interface PhysicalEnrollmentResult { +interface PhysicalEnrollmentResult { readonly device: PhysicalDevice; readonly reenrolled: boolean; } @@ -65,10 +65,15 @@ export interface PhysicalEnrollmentOptions { readonly claims: Pick; readonly decisions: Pick; readonly drivers: { get(platform: Platform): Driver }; - readonly registry: Pick< - Registry, - "enrollPhysical" | "reenrollPhysical" | "snapshot" | "unenrollPhysical" - >; + readonly registry: PhysicalEnrollmentRegistry; +} + +/** The four registry members enrollment uses; `Registry` is what the composition root passes. */ +export interface PhysicalEnrollmentRegistry { + readonly snapshot: Pick; + enrollPhysical(input: PhysicalEnrollmentInput): Promise; + reenrollPhysical(deviceId: string, input: PhysicalEnrollmentInput): Promise; + unenrollPhysical(deviceId: string): Promise; } /** @@ -124,6 +129,7 @@ export class PhysicalEnrollment { } } + // fallow-ignore-next-line unused-class-member -- called through the dispatcher's enrollment port. async remove(id: string): Promise { return this.options.decisions.run(() => { const existing = this.#enrolled(id); diff --git a/src/core/registry.ts b/src/core/registry.ts index f9e215906..3563f1e45 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -632,6 +632,7 @@ export class Registry implements LeaseRequestStore { * Writes a new physical device `ready` (ADR 0022 §2): the first enrollment of a canonical ID. * `device.enrolled` follows the commit. */ + // fallow-ignore-next-line unused-class-member -- called through PhysicalEnrollment's registry port. async enrollPhysical(input: PhysicalEnrollmentInput): Promise { const now = this.options.clock.now(); const record: PhysicalDevice = { @@ -661,6 +662,7 @@ export class Registry implements LeaseRequestStore { * class and app list replaced, `ready`, the quarantine fields cleared, the registry ID kept. * Only a `ready` or `quarantined` record is rewritten. */ + // fallow-ignore-next-line unused-class-member -- called through PhysicalEnrollment's registry port. async reenrollPhysical( deviceId: string, input: PhysicalEnrollmentInput, @@ -700,6 +702,7 @@ export class Registry implements LeaseRequestStore { * Writes an enrolled physical device `deleted` (ADR 0022 §2): it is forgotten, and nothing is * done to the device. `device.deleted` follows the commit with `initiator: "operator"`. */ + // fallow-ignore-next-line unused-class-member -- called through PhysicalEnrollment's registry port. async unenrollPhysical(deviceId: string): Promise { const { device, index } = this.#requireDeviceRecord(deviceId); if (!device.physical || (device.state !== "ready" && device.state !== "quarantined")) { diff --git a/src/drivers/android/index.ts b/src/drivers/android/index.ts index abdc46ac5..1ebef0ab5 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -1015,7 +1015,6 @@ export class AndroidDriver implements Driver { }); } - // fallow-ignore-next-line unused-class-member -- called through the core's Driver interface. async inspectPhysical(_id: string): Promise { return { enrollable: false, diff --git a/src/drivers/ios/index.ts b/src/drivers/ios/index.ts index f98d3b5be..29af8d9e7 100644 --- a/src/drivers/ios/index.ts +++ b/src/drivers/ios/index.ts @@ -1282,7 +1282,6 @@ export class IosSimctlDriver implements Driver { await this.#legacySimctl(["delete", udid], COMMAND_TIMEOUT_MS); } - // fallow-ignore-next-line unused-class-member -- called through the core's Driver interface. async inspectPhysical(_id: string): Promise { return { enrollable: false, From 64a927f2fc3639c67d3ee1ba45896f1c1b6b93c0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 10:04:25 +0200 Subject: [PATCH 08/22] fix: fallow findings for enrollment: no class-indexed type, smaller fake read (#437) --- src/core/fake-driver.ts | 22 +++++++++++++--------- src/core/physical-enrollment.ts | 4 ++-- src/daemon/server.ts | 1 + 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/core/fake-driver.ts b/src/core/fake-driver.ts index 5de1f299d..e7fe873c3 100644 --- a/src/core/fake-driver.ts +++ b/src/core/fake-driver.ts @@ -561,15 +561,7 @@ export class FakeDriver implements Driver { return { enrollable: false, message: `${id} is not connected`, reason: "not-connected" }; } if (device.refusal !== undefined) return { enrollable: false, ...device.refusal }; - const [canonical] = device.ids; - return { - apps: [...(device.apps ?? [])], - class: device.class ?? "phone", - driverDeviceId: canonical ?? id, - enrollable: true, - model: device.model ?? "Fake Phone", - osVersion: device.osVersion ?? "1.0", - }; + return readPhysical(device, id); } async listCatalog(): Promise { @@ -761,3 +753,15 @@ function compareVersions(left: string, right: string): number { return left.localeCompare(right); } + +/** What the fake reports for an enrollable scripted device: its canonical ID is its first ID. */ +function readPhysical(device: FakePhysicalDevice, id: string): PhysicalInspection { + return { + apps: [...(device.apps ?? [])], + class: device.class ?? "phone", + driverDeviceId: device.ids[0] ?? id, + enrollable: true, + model: device.model ?? "Fake Phone", + osVersion: device.osVersion ?? "1.0", + }; +} diff --git a/src/core/physical-enrollment.ts b/src/core/physical-enrollment.ts index 2f5ca8c97..3344ef32c 100644 --- a/src/core/physical-enrollment.ts +++ b/src/core/physical-enrollment.ts @@ -1,7 +1,7 @@ import type { DeviceOperationClaims } from "./device-operation-claims.js"; import type { PhysicalDevice, Platform } from "./domain.js"; import type { Driver, DriverRefusalReason } from "./driver.js"; -import type { PhysicalEnrollmentInput, Registry } from "./registry.js"; +import type { PhysicalEnrollmentInput, RegistrySnapshot } from "./registry.js"; import type { SerializedDecision } from "./serialized-decision.js"; /** Every driver reason, and the one only the core can know. */ @@ -70,7 +70,7 @@ export interface PhysicalEnrollmentOptions { /** The four registry members enrollment uses; `Registry` is what the composition root passes. */ export interface PhysicalEnrollmentRegistry { - readonly snapshot: Pick; + readonly snapshot: Pick; enrollPhysical(input: PhysicalEnrollmentInput): Promise; reenrollPhysical(deviceId: string, input: PhysicalEnrollmentInput): Promise; unenrollPhysical(deviceId: string): Promise; diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 5e0d78549..fecb32ea4 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -295,6 +295,7 @@ type DaemonHealth = "starting" | "running" | "failed"; * needs a new dependency (`device.exec` added two). The two closures the dispatcher needs from * the server's own live state are passed in rather than reached for. */ +// fallow-ignore-next-line complexity -- one conditional spread per optional dependency, as the comment above says. function buildDispatcher( options: DaemonServerCommonOptions & DaemonServerEngineOptions, hooks: { From b769a59bd865e58d71bfc8ac9e6bf98bd5b64cdc Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 10:13:49 +0200 Subject: [PATCH 09/22] test: the console's devices table has a Type column; drop stale suppressions (#437) --- e2e/console/workers.spec.ts | 9 +++++---- src/core/registry.ts | 3 --- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index e31f216b7..1a87ce3b5 100644 --- a/e2e/console/workers.spec.ts +++ b/e2e/console/workers.spec.ts @@ -325,10 +325,11 @@ test.describe("the workers views", () => { const cells = deviceRow(page, device.id).getByRole("cell"); await expect(cells.nth(1)).toHaveText(device.state); await expect(cells.nth(3)).toHaveText(device.spec.platform === "ios" ? "iOS" : "Android"); - await expect(cells.nth(4)).toHaveText(device.spec.model); - await expect(cells.nth(5)).toHaveText(device.spec.osVersion); - await expect(cells.nth(6)).toHaveText(device.mode); - await expect(cells.nth(7)).toHaveText(device.spec.imageTag ?? "—"); + await expect(cells.nth(4)).toHaveText(device.physical ? "Physical" : "Virtual"); + await expect(cells.nth(5)).toHaveText(device.spec.model); + await expect(cells.nth(6)).toHaveText(device.spec.osVersion); + await expect(cells.nth(7)).toHaveText(device.mode); + await expect(cells.nth(8)).toHaveText(device.spec.imageTag ?? "—"); const held = view?.leases.find((candidate) => candidate.deviceId === device.id); if (held === undefined) { // A ready device: the worker view carries no time for that state. diff --git a/src/core/registry.ts b/src/core/registry.ts index 3563f1e45..f9e215906 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -632,7 +632,6 @@ export class Registry implements LeaseRequestStore { * Writes a new physical device `ready` (ADR 0022 §2): the first enrollment of a canonical ID. * `device.enrolled` follows the commit. */ - // fallow-ignore-next-line unused-class-member -- called through PhysicalEnrollment's registry port. async enrollPhysical(input: PhysicalEnrollmentInput): Promise { const now = this.options.clock.now(); const record: PhysicalDevice = { @@ -662,7 +661,6 @@ export class Registry implements LeaseRequestStore { * class and app list replaced, `ready`, the quarantine fields cleared, the registry ID kept. * Only a `ready` or `quarantined` record is rewritten. */ - // fallow-ignore-next-line unused-class-member -- called through PhysicalEnrollment's registry port. async reenrollPhysical( deviceId: string, input: PhysicalEnrollmentInput, @@ -702,7 +700,6 @@ export class Registry implements LeaseRequestStore { * Writes an enrolled physical device `deleted` (ADR 0022 §2): it is forgotten, and nothing is * done to the device. `device.deleted` follows the commit with `initiator: "operator"`. */ - // fallow-ignore-next-line unused-class-member -- called through PhysicalEnrollment's registry port. async unenrollPhysical(deviceId: string): Promise { const { device, index } = this.#requireDeviceRecord(deviceId); if (!device.physical || (device.state !== "ready" && device.state !== "quarantined")) { From 964d292574da951feed329219ae772cfe8e8b257 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 10:14:21 +0200 Subject: [PATCH 10/22] test: the console spec's status type carries physical (#437) --- e2e/console/workers.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index 1a87ce3b5..cbb56efb8 100644 --- a/e2e/console/workers.spec.ts +++ b/e2e/console/workers.spec.ts @@ -38,6 +38,7 @@ interface Status { readonly id: string; readonly state: string; readonly mode: string; + readonly physical: boolean; readonly spec: { readonly platform: string; readonly model: string; From 116a2f944325c7aaa3713d60e0f2d38951ac3254 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 10:20:50 +0200 Subject: [PATCH 11/22] fix: read an enrolled device by its canonical ID under the claim; delete comments that say what; docs that named the old facts (#437) 0 failing -> 0 failing --- docs/CLI.md | 10 +++++----- docs/CONSOLE.md | 5 +++-- docs/HTTP-API.md | 7 ++++--- docs/internal/ARCHITECTURE.md | 11 +++++++---- e2e/fake-driver/fake-driver.ts | 4 ---- e2e/fake-driver/types.ts | 12 ------------ e2e/physical-enrollment.test.ts | 6 ------ src/bus/index.ts | 9 --------- src/cli/index.ts | 9 +-------- src/contract/errors.ts | 6 ------ src/contract/operations.test.ts | 1 - src/contract/operations.ts | 15 +-------------- src/contract/protocol.ts | 2 +- src/contract/schemas.ts | 1 - src/core/create-core.ts | 1 - src/core/device-operation-claims.ts | 3 +-- src/core/fake-driver.ts | 9 --------- src/core/physical-enrollment.test.ts | 14 ++++++++++++-- src/core/physical-enrollment.ts | 16 +--------------- src/core/quarantine-coordinator.ts | 1 - src/core/registry.ts | 14 -------------- src/daemon/dispatcher.ts | 7 ------- src/daemon/server.ts | 1 - src/gateway/worker-registry.ts | 2 +- 24 files changed, 37 insertions(+), 129 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index c9bc05165..ab32f8823 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -115,9 +115,9 @@ implement them: means for `USAGE` and `BAD_REQUEST`. Neither is retryable as written: the fix is a different command, or the same command against a different daemon. `UNSUPPORTED_IN_GATEWAY_MODE` in particular is permanent, not provisional: - `nuke`, `cleanup`, `doctor`, `driver.passthrough`, `component list` and - `component remove` stay per-worker operations rather than waiting on some - later fleet-wide version. `component install` without a worker flag answers + `nuke`, `cleanup`, `doctor`, `driver.passthrough`, `component list`, + `component remove`, `device add` and `device remove` stay per-worker + operations rather than waiting on some later fleet-wide version. `component install` without a worker flag answers it too; with `--worker` or `--all-workers` it installs on the workers you name. - `UNSUPPORTED_IN_WORKER_MODE` is the same refusal from the other side, and @@ -1805,7 +1805,7 @@ add again gets a new record. emulator, including every one Simlock manages) or `enrolled-on-other-platform`. The message is the driver's own, and says what to do. -- `DEVICE_BUSY` (exit 11): the device is `leased`, is being `reclaiming`, or +- `DEVICE_BUSY` (exit 11): the device is `leased`, is `reclaiming`, or another `device add` or `device remove` holds it (`busy`). - `NO_DRIVER` (exit 12): this machine has no driver for the platform. @@ -2159,7 +2159,7 @@ simlock daemon stop && simlock daemon start Several commands (`list`, `cleanup`, `nuke`, `events`, `config get`, `daemon stop`, `token create|list|revoke`, -`worker list|drain|undrain|remove`, and cross-process `lease renew` +`device add|remove`, `worker list|drain|undrain|remove`, and cross-process `lease renew` and `release`) need the daemon's `admin` role. The CLI resolves a credential to send at handshake, in order: diff --git a/docs/CONSOLE.md b/docs/CONSOLE.md index b6b9b1b50..cf91d352f 100644 --- a/docs/CONSOLE.md +++ b/docs/CONSOLE.md @@ -147,8 +147,9 @@ such as **All 12 workers**, takes you to the cards for the rest. Select a worker to open its page. It adds: -- **Devices**: every device on the worker, with its state, platform, model, - runtime, mode (empty for a physical device, which has none) and image tag. A leased device shows how long ago its lease +- **Devices**: every device on the worker, with its state, platform, type + (Physical or Virtual), model, runtime, mode and image tag (both `—` for a + physical device, which has neither). A leased device shows how long ago its lease was granted, and a device being provisioned or reclaimed on a connected worker how long that has taken so far. Other states show `—`: the daemon reports no time for them. diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 74048ee8c..32974a8ea 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -1449,9 +1449,10 @@ behind this is not reachable right now" is the same answer in all four cases, and a client with one retry rule for `transport` should not need a second one because the unreachable thing happened to be a worker. -`UNSUPPORTED_IN_GATEWAY_MODE` comes from three routes in this version, -`POST /v1/components/install` without `workers`, `GET /v1/components` and -`DELETE /v1/components/{platform}/{version}` asked of a gateway — `nuke`, +`UNSUPPORTED_IN_GATEWAY_MODE` comes from five routes in this version, +`POST /v1/components/install` without `workers`, `GET /v1/components`, +`DELETE /v1/components/{platform}/{version}`, `POST /v1/devices` and +`DELETE /v1/devices/{id}` asked of a gateway — `nuke`, `cleanup`, and `doctor` are absent from the HTTP surface (see [Not implemented](#not-implemented)), and the status is fixed so adding diff --git a/docs/internal/ARCHITECTURE.md b/docs/internal/ARCHITECTURE.md index e653f89b9..bcf08c377 100644 --- a/docs/internal/ARCHITECTURE.md +++ b/docs/internal/ARCHITECTURE.md @@ -206,8 +206,9 @@ agent / console ──token auth──> │ HTTP frontend + unix socket on HTTP — that is how agents reach it and what the uplink upgrades from — so `http.enabled: false` in gateway mode fails the start rather than being silently overridden. `nuke.run`, `cleanup.run`, `doctor.run`, - `driver.passthrough`, `component.install`, `component.list` and - `component.remove` answer `UNSUPPORTED_IN_GATEWAY_MODE` permanently: they act on one machine's devices + `driver.passthrough`, `component.install`, `component.list`, + `component.remove`, `device.add` and `device.remove` answer + `UNSUPPORTED_IN_GATEWAY_MODE` permanently: they act on one machine's devices or components, and stay per-worker. Installing through a gateway is its own gateway-only operation, `worker.install-component`, which names the workers (see "Through a gateway" under Components). The lease lifecycle @@ -287,7 +288,8 @@ path (`simlock daemon stop`, then start the new daemon) exists at all. **Two roles**, `agent` and `admin`, declared in `src/contract/roles.ts`. Read-only and lease-lifecycle operations are `agent`; anything that reads or mutates state outside the caller's own leases (`list.get`, `cleanup.run`, -`nuke.run`, `config.get`, `daemon.stop`, `events.*`, `token.*`) is `admin`. +`nuke.run`, `config.get`, `daemon.stop`, `device.add`, `device.remove`, +`events.*`, `token.*`) is `admin`. `doctor.run` is the one operation whose role is a function of its input rather than a fixed value: `fix: false` is agent-visible (read-only, but it shells out per device); `fix: true` is admin-only. @@ -1509,7 +1511,8 @@ are: caller waiting on it (see "Release hands the purge off"). - `CapacityCoordinator` owns provisioning and running reservations while the configured `CapacityStrategy` decides the limits. `DeviceOperationClaims` excludes - overlapping boot, eviction, cleanup, and nuke operations per device. + overlapping boot, eviction, cleanup, nuke, recovery, reclaim and enrollment + operations per device. - `CleanupReaper` evaluates pure rules and directly calls `CleanupActionExecutor`; the executor revalidates registry ownership, lease/state safety, and delegates the driver operation to diff --git a/e2e/fake-driver/fake-driver.ts b/e2e/fake-driver/fake-driver.ts index d5a4a828f..24e26ce01 100644 --- a/e2e/fake-driver/fake-driver.ts +++ b/e2e/fake-driver/fake-driver.ts @@ -475,10 +475,6 @@ export class OutOfProcessFakeDriver implements Driver { }; } - /** - * Reads the scripted physical device `id` names, from the script as it is now. An ID no entry - * lists is `not-connected`, as a device that is not plugged in is. - */ async inspectPhysical(id: string): Promise { const script = await this.#beforeCall("inspectPhysical", [id]); const device = script.physicalDevices?.find((candidate) => candidate.ids.includes(id)); diff --git a/e2e/fake-driver/types.ts b/e2e/fake-driver/types.ts index a58129cf8..f1c8eb126 100644 --- a/e2e/fake-driver/types.ts +++ b/e2e/fake-driver/types.ts @@ -24,7 +24,6 @@ export type FakeDriverOperation = | "removeComponent" /** The root re-proof `doctor --purge-orphans` takes before its first destroy. */ | "revalidateRoot" - /** The read `device add` makes of a physical device: logged with the ID the operator typed. */ | "inspectPhysical"; export type FakeDriverEstimateOperation = "provision" | "boot" | "reclaim"; @@ -37,7 +36,6 @@ export type FakeDriverErrorSpec = | { readonly type: "DriverCrashError"; readonly message?: string } | { readonly type: "generic"; readonly message?: string }; -/** Why a driver refuses to enrol a device; the core adds `enrolled-on-other-platform` itself. */ export type FakeDriverRefusalReason = | "not-connected" | "network-only" @@ -49,12 +47,6 @@ export type FakeDriverRefusalReason = | "unreadable" | "virtual-device"; -/** - * One physical device the fake driver can read. `ids` lists every ID the device answers to: the - * first is its canonical ID, the rest are aliases an operator may type. A `refusal` makes every - * one of those IDs refused with its reason and message. The script is re-read on every call, so - * a test can change a device's model, OS version, class or apps between two `device add` calls. - */ export interface ScriptedPhysicalDevice { readonly ids: readonly string[]; readonly model?: string; @@ -149,10 +141,6 @@ export interface FakeDriverPlatformScript { readonly reclaimStrategy?: "erase" | "snapshot" | "wipe"; readonly failures?: Partial>; readonly managedReality?: ScriptedManagedReality; - /** - * The physical devices `inspectPhysical` reads. An ID no entry lists is refused - * `not-connected`, as a device that is not plugged in is. - */ readonly physicalDevices?: readonly ScriptedPhysicalDevice[]; /** * Environment the grant for this platform's devices should carry, standing in for the diff --git a/e2e/physical-enrollment.test.ts b/e2e/physical-enrollment.test.ts index 5d925f66f..278b22914 100644 --- a/e2e/physical-enrollment.test.ts +++ b/e2e/physical-enrollment.test.ts @@ -3,12 +3,6 @@ import { describe, expect, it } from "vitest"; import type { FakeDriverRefusalReason, ScriptedPhysicalDevice } from "./fake-driver/types.js"; import { freeLoopbackPort, waitFor, withDaemon, type TestEnv } from "./helpers/index.js"; -/** - * `simlock device add` and `device remove` (ADR 0022 §2, §6): an admin enrols and unenrols a - * physical device on the host it is plugged into. Only the fake driver reads a device here; the - * scripted devices below are what its `inspectPhysical` answers from. - */ - const UDID = "00008110-001A2B3C4D5E"; const ALIAS = "7A1C0E52-6B7D-4F1E-9C1A-2D3E4F5A6B7C"; const APPS = Array.from({ length: 12 }, (_, index) => `com.example.app${String(index)}`); diff --git a/src/bus/index.ts b/src/bus/index.ts index bd5b3ad8b..300058aed 100644 --- a/src/bus/index.ts +++ b/src/bus/index.ts @@ -192,20 +192,11 @@ export interface EventMap { readonly deviceRoot: string; }; "device.shutdown": { readonly deviceId: string; readonly initiator: string }; - /** - * Removed from the registry, and from disk for a virtual device (ADR 0022 §9). `physical` is - * the record's own flag; `initiator` is `operator` for a physical device unenrolled by - * `device remove`. - */ "device.deleted": { readonly deviceId: string; readonly initiator: string; readonly physical: boolean; }; - /** - * An admin enrolled a physical device with `device add`, or enrolled one already enrolled - * again (`reenrolled`), which replaced the record's model, OS version, class and app list. - */ "device.enrolled": { readonly deviceId: string; readonly platform: string; diff --git a/src/cli/index.ts b/src/cli/index.ts index df6ad5589..1cd9669ae 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1954,12 +1954,7 @@ const DEVICE_USAGE = "Usage: simlock device add --platform [--json]\n" + " simlock device remove [--json]\n"; -/** - * ADR 0022 §2: `device add` and `device remove` render the daemon's `device.add` and - * `device.remove` as one line, or with `--json` as the operation's output. Argument parsing and - * rendering only: what can be enrolled, and why not, is the platform driver's answer. `remove` - * asks for nothing: it changes nothing on the device (safety rule 5). - */ +// `device remove` asks for no confirmation: it changes nothing on the device (safety rule 5). async function runDevice( argv: readonly string[], environment: CliEnvironment, @@ -1991,7 +1986,6 @@ async function runDevice( } } -/** `device add`'s arguments: one ID and `--platform`; `device remove`'s: one ID. */ function parseDeviceArgs( command: "add" | "remove", argv: readonly string[], @@ -2045,7 +2039,6 @@ function describeRemoved(result: { readonly driverDeviceId: string }): string { return `Removed ${result.driverDeviceId}. It is no longer enrolled; nothing on the device changed.`; } -/** `Enrolled iPhone 15 Pro (iOS 18.4) as [ again], 12 user apps recorded.` */ function describeEnrolled(result: { readonly appCount: number; readonly driverDeviceId: string; diff --git a/src/contract/errors.ts b/src/contract/errors.ts index 7a0643dce..81d36cd12 100644 --- a/src/contract/errors.ts +++ b/src/contract/errors.ts @@ -11,7 +11,6 @@ */ import type { DeviceClass, Platform } from "./schemas.js"; -/** Why a device cannot be enrolled (ADR 0022 §2); `DEVICE_NOT_ENROLLABLE`'s `reason`. */ const DEVICE_NOT_ENROLLABLE_REASONS = [ "not-connected", "network-only", @@ -122,12 +121,7 @@ export interface ErrorDetailsMap { readonly reason: DeviceNotEnrollableReason; readonly canonicalId?: string; }; - /** - * ADR 0022 §2: `device.add` or `device.remove` of a device something else holds: `leased`, a - * `reclaiming` device or one whose `reclaim` claim is held, or `busy` under another enrollment. - */ DEVICE_BUSY: { readonly id: string; readonly reason: "leased" | "reclaiming" | "busy" }; - /** `device.remove` of an ID with no enrolled device: no record, or a `deleted` one. */ UNKNOWN_DEVICE: { readonly id: string }; /** A `simlock ` verb the owning driver will not proxy (ADR 0001, decision 7). Carries * the tool so a caller can say which wrapper refused without re-parsing the message. */ diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index 62fcf235a..9a831ed3e 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -66,7 +66,6 @@ const ROLE_MATRIX: ReadonlyArray<{ }, // ADR 0010 §8: removal is destructive, so admin-only. { name: "component.remove", input: { platform: "ios", version: "26.4" }, role: "admin" }, - // ADR 0022 §2: enrolling and unenrolling a physical device is an operator's decision. { name: "device.add", input: { id: "00008110-001A2B3C4D5E", platform: "ios" }, role: "admin" }, { name: "device.remove", input: { id: "00008110-001A2B3C4D5E" }, role: "admin" }, ]; diff --git a/src/contract/operations.ts b/src/contract/operations.ts index be157b045..320c2200c 100644 --- a/src/contract/operations.ts +++ b/src/contract/operations.ts @@ -622,7 +622,7 @@ export const leaseReleaseAll = defineOperation({ * - a **worker** returns its own registry records -- the full `deviceRecordSchema`, which an * operator inspecting one machine legitimately wants; * - a **gateway** returns the fleet, and cannot return that shape: it has never held a - * `driverDeviceId` or a driver's private `driverData`, and fabricating either would be a + * virtual device's `driverDeviceId` or a driver's private `driverData`, and fabricating either would be a * lie about a device it does not own. It answers the narrower `statusDeviceSchema` (plus * `workerId`) instead -- the same devices `status.get` reports. * @@ -991,9 +991,6 @@ export const componentRemove = defineOperation({ }), }); -// ---- device.add, device.remove (ADR 0022 §2) ----------------------------------------------- - -/** The longest ID `device.add` and `device.remove` take: an ID is a claim, bounded before use. */ const ENROLLED_DEVICE_ID_MAX_LENGTH = 200; /** An ID as an operator types it: no whitespace or control character, and not an option. */ @@ -1012,12 +1009,6 @@ function isControlCharacter(character: string): boolean { return code <= 0x1f || (code >= 0x7f && code <= 0x9f); } -/** - * Enrolls a physical device plugged into the host this daemon runs on, admin-only. The driver - * reads the device; the record is written only if the driver says it can be enrolled. `id` in - * the output is the registry ID, `driverDeviceId` the canonical one. `reenrolled` is true when - * the device already had a record, which kept its registry ID and took a fresh read. - */ // fallow-ignore-next-line unused-export -- consumed only through the OPERATIONS registry, not by name; still public contract surface. export const deviceAdd = defineOperation({ name: "device.add", @@ -1036,10 +1027,6 @@ export const deviceAdd = defineOperation({ }), }); -/** - * Forgets an enrolled physical device, admin-only. `id` is the canonical ID. Nothing is sent - * to the device. - */ // fallow-ignore-next-line unused-export -- consumed only through the OPERATIONS registry, not by name; still public contract surface. export const deviceRemove = defineOperation({ name: "device.remove", diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index 8cdf4455e..5eb680d70 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -14,7 +14,7 @@ export interface ProtocolRange { const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().int() }); /** - * The range this contract's daemon speaks. Both ends are 23, and every move that got it there + * The range this contract's daemon speaks. Both ends are 24, and every move that got it there * was breaking with no shim kept behind them, which is exactly when ADR 0003 §6's honesty * rule says a range must *not* widen: ADR 0004 removed `lease.heartbeat` and `mode` from the * wire (taking it to 4), and ADR 0005 adds `device.exec` and its `output` push family, a diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 14039c579..08764af18 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -220,7 +220,6 @@ export const statusDeviceSchema = z mode: deviceModeSchema.optional(), /** Present exactly when `physical` is `true`. */ class: physicalClassSchema.optional(), - /** A physical device's canonical ID, the ID `device remove` takes; never set on a virtual one. */ driverDeviceId: z.string().optional(), /** * ADR 0009 §6: whether the device's pool mode (its spec's mode, full when it names none) is the diff --git a/src/core/create-core.ts b/src/core/create-core.ts index bb27c964d..3c75d91f5 100644 --- a/src/core/create-core.ts +++ b/src/core/create-core.ts @@ -128,7 +128,6 @@ export interface Core { readonly deviceLifecycle: ManagedDeviceLifecycle; readonly doctor: Doctor; readonly drivers: DriverCatalog; - /** `device add` and `device remove`: enrols and unenrols physical devices (ADR 0022 §2). */ readonly enrollment: PhysicalEnrollment; readonly nuke: NukeService; readonly provisioner: DeviceProvisioner; diff --git a/src/core/device-operation-claims.ts b/src/core/device-operation-claims.ts index c35e631d4..cd0b3b73a 100644 --- a/src/core/device-operation-claims.ts +++ b/src/core/device-operation-claims.ts @@ -6,8 +6,7 @@ * `reclaiming` devices) never mistakes it for one left over from a previous crash, * and so `simlock doctor` never reads a long-but-healthy erase as a stalled * transition. Every release takes one, since none of them wait for the purge. - * See `LeaseReleaseCoordinator#reclaimInBackground`. `enrollment` is a physical device held from - * `device add`'s second read to its write (ADR 0022 §2). + * See `LeaseReleaseCoordinator#reclaimInBackground`. */ export type DeviceOperation = | "boot" diff --git a/src/core/fake-driver.ts b/src/core/fake-driver.ts index e7fe873c3..0bc2f7dd8 100644 --- a/src/core/fake-driver.ts +++ b/src/core/fake-driver.ts @@ -43,7 +43,6 @@ export type FakeDriverOperation = | "shutdown" | "destroy" | "listManaged" - /** Logged with the ID the operator typed. */ | "inspectPhysical" | "listCatalog" | "listComponents" @@ -55,10 +54,6 @@ export type FakeDriverOperation = export type DriverEstimateOperation = "provision" | "boot" | "reclaim"; -/** - * One physical device the fake can read. `ids` lists every ID it answers to: the first is its - * canonical ID, the rest are aliases. A `refusal` refuses every one of them. - */ export interface FakePhysicalDevice { readonly ids: readonly string[]; readonly model?: string; @@ -152,7 +147,6 @@ export interface FakeDriverOptions { readonly legacyDevices?: Readonly>; readonly reclaimResult?: "ready" | "shutdown"; readonly reclaimStrategy?: "erase" | "snapshot" | "wipe"; - /** The physical devices `inspectPhysical` reads; an ID none lists is `not-connected`. */ readonly physicalDevices?: readonly FakePhysicalDevice[]; } @@ -213,7 +207,6 @@ export class FakeDriver implements Driver { /** Bumped on every `makeReady` boot -- mirrors a real driver reassigning a port per boot. */ readonly #bootCounts = new Map(); #managedReality: DriverReality | undefined; - /** What `setPhysicalDevices` replaced, or the option when it never ran. */ #physicalDevices: readonly FakePhysicalDevice[] | undefined; readonly #scriptedPhysicalDevices: FakeDriverOptions["physicalDevices"]; @@ -548,7 +541,6 @@ export class FakeDriver implements Driver { }; } - /** Replaces the scripted physical devices; the next `inspectPhysical` reads the new ones. */ setPhysicalDevices(devices: readonly FakePhysicalDevice[]): void { this.#physicalDevices = devices; } @@ -754,7 +746,6 @@ function compareVersions(left: string, right: string): number { return left.localeCompare(right); } -/** What the fake reports for an enrollable scripted device: its canonical ID is its first ID. */ function readPhysical(device: FakePhysicalDevice, id: string): PhysicalInspection { return { apps: [...(device.apps ?? [])], diff --git a/src/core/physical-enrollment.test.ts b/src/core/physical-enrollment.test.ts index 8e9e5b860..b5c1d6bf1 100644 --- a/src/core/physical-enrollment.test.ts +++ b/src/core/physical-enrollment.test.ts @@ -60,7 +60,6 @@ function enrolledEvents(harness: Harness) { return harness.eventBus.replay().filter((entry) => entry.event === "device.enrolled"); } -/** Leases the enrolled device and releases it into quarantine, as a failed purge would. */ async function quarantine(harness: Harness, deviceId: string): Promise { const lease = await harness.registry.createLease({ deviceId, @@ -73,7 +72,6 @@ async function quarantine(harness: Harness, deviceId: string): Promise { await harness.registry.enterQuarantine(deviceId, 9_000); } -/** Holds the n-th `inspectPhysical` call until `release` runs; the others answer at once. */ function holdInspection(driver: FakeDriver, callNumber: number) { const original = driver.inspectPhysical.bind(driver); let release!: () => void; @@ -135,6 +133,18 @@ describe("PhysicalEnrollment", () => { ]); }); + it("reads the device under the claim by its canonical ID, whatever ID the operator typed", async () => { + const harness = await build(); + await harness.core.enrollment.add("ios", UDID); + + await harness.core.enrollment.add("ios", ALIAS); + + const reads = harness.driver.calls + .filter((call) => call.operation === "inspectPhysical") + .map((call) => call.arguments[0]); + expect(reads).toEqual([UDID, ALIAS, UDID]); + }); + it("leaves one non-deleted record and two device.enrolled for two adds of one new device at once, the second reenrolled", async () => { const harness = await build(); diff --git a/src/core/physical-enrollment.ts b/src/core/physical-enrollment.ts index 3344ef32c..593c9fa01 100644 --- a/src/core/physical-enrollment.ts +++ b/src/core/physical-enrollment.ts @@ -4,10 +4,8 @@ import type { Driver, DriverRefusalReason } from "./driver.js"; import type { PhysicalEnrollmentInput, RegistrySnapshot } from "./registry.js"; import type { SerializedDecision } from "./serialized-decision.js"; -/** Every driver reason, and the one only the core can know. */ export type DeviceNotEnrollableReason = DriverRefusalReason | "enrolled-on-other-platform"; -/** A device `device.add` will not enrol (ADR 0022 §2): the reason, in the driver's own words. */ export class DeviceNotEnrollableError extends Error { constructor( readonly platform: Platform, @@ -23,7 +21,6 @@ export class DeviceNotEnrollableError extends Error { } } -/** The device is leased, being reclaimed or held by another enrollment. */ export class DeviceBusyError extends Error { constructor( readonly id: string, @@ -40,7 +37,6 @@ export class DeviceBusyError extends Error { } } -/** `device.remove` of an ID with no enrolled device. */ export class UnknownEnrolledDeviceError extends Error { constructor(readonly id: string) { super(`${id} is not an enrolled device`); @@ -48,7 +44,6 @@ export class UnknownEnrolledDeviceError extends Error { } } -/** A driver's `inspectPhysical` threw, or named another device than the one asked for. */ class PhysicalInspectionError extends Error { constructor(message: string) { super(message); @@ -68,7 +63,6 @@ export interface PhysicalEnrollmentOptions { readonly registry: PhysicalEnrollmentRegistry; } -/** The four registry members enrollment uses; `Registry` is what the composition root passes. */ export interface PhysicalEnrollmentRegistry { readonly snapshot: Pick; enrollPhysical(input: PhysicalEnrollmentInput): Promise; @@ -76,11 +70,6 @@ export interface PhysicalEnrollmentRegistry { unenrollPhysical(deviceId: string): Promise; } -/** - * Enrols and unenrols physical devices (ADR 0022 §2). The driver reads a device and decides every - * refusal; this class only relays it, finds the one record a canonical ID has, and keeps it - * from changing under a lease, a reclaim or another enrollment. Never writes to a device. - */ export class PhysicalEnrollment { constructor(private readonly options: PhysicalEnrollmentOptions) {} @@ -139,7 +128,6 @@ export class PhysicalEnrollment { }); } - /** Simlock's own simulators and emulators are never enrolled, by either of their names. */ #refuseVirtual(platform: Platform, id: string): void { const own = this.options.registry.snapshot.virtualDevices.find( (device) => @@ -163,7 +151,7 @@ export class PhysicalEnrollment { ): Promise> { let inspection: Awaited>; try { - inspection = await driver.inspectPhysical(id); + inspection = await driver.inspectPhysical(canonicalId ?? id); } catch (error: unknown) { throw new PhysicalInspectionError( `Reading ${id} failed: ${error instanceof Error ? error.message : String(error)}`, @@ -187,7 +175,6 @@ export class PhysicalEnrollment { }; } - /** The one non-deleted physical record a canonical ID has, if any. */ #enrolled(canonicalId: string): PhysicalDevice | undefined { return this.options.registry.snapshot.devices.find( (device): device is PhysicalDevice => @@ -195,7 +182,6 @@ export class PhysicalEnrollment { ); } - /** The one busy check `add` and `remove` share: what holds a device, by name. */ #requireFree(device: PhysicalDevice): void { if (device.state === "leased") throw new DeviceBusyError(device.driverDeviceId, "leased"); const held = this.options.claims.claim(device.id); diff --git a/src/core/quarantine-coordinator.ts b/src/core/quarantine-coordinator.ts index 2d2c247a5..42e6fd733 100644 --- a/src/core/quarantine-coordinator.ts +++ b/src/core/quarantine-coordinator.ts @@ -41,7 +41,6 @@ export interface QuarantineRetryConfig { } export interface QuarantineCoordinatorOptions { - /** A retry holds the device's `reclaim` claim from its first driver call to its last write. */ readonly claims: Pick; readonly clock: Clock; readonly config: QuarantineRetryConfig; diff --git a/src/core/registry.ts b/src/core/registry.ts index f9e215906..840b61f6e 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -105,7 +105,6 @@ export interface RegisterDeviceInput { readonly provisionDuration: number; } -/** What a driver's read of a physical device gives a record: everything `device.add` writes. */ export interface PhysicalEnrollmentInput { readonly platform: Platform; readonly driverDeviceId: string; @@ -628,10 +627,6 @@ export class Registry implements LeaseRequestStore { return deleted; } - /** - * Writes a new physical device `ready` (ADR 0022 §2): the first enrollment of a canonical ID. - * `device.enrolled` follows the commit. - */ async enrollPhysical(input: PhysicalEnrollmentInput): Promise { const now = this.options.clock.now(); const record: PhysicalDevice = { @@ -656,11 +651,6 @@ export class Registry implements LeaseRequestStore { return cloneDevice(record) as PhysicalDevice; } - /** - * Rewrites an enrolled physical device from a fresh read (ADR 0022 §2): model, OS version, - * class and app list replaced, `ready`, the quarantine fields cleared, the registry ID kept. - * Only a `ready` or `quarantined` record is rewritten. - */ async reenrollPhysical( deviceId: string, input: PhysicalEnrollmentInput, @@ -696,10 +686,6 @@ export class Registry implements LeaseRequestStore { return cloneDevice(updated) as PhysicalDevice; } - /** - * Writes an enrolled physical device `deleted` (ADR 0022 §2): it is forgotten, and nothing is - * done to the device. `device.deleted` follows the commit with `initiator: "operator"`. - */ async unenrollPhysical(deviceId: string): Promise { const { device, index } = this.#requireDeviceRecord(deviceId); if (!device.physical || (device.state !== "ready" && device.state !== "quarantined")) { diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index cd9941f7e..2d8045137 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -122,7 +122,6 @@ export interface DispatcherOptions { * Injected because `error-code.ts` imports this module. Unset or `undefined`: `INTERNAL`. */ readonly errorCode?: (error: unknown) => string | undefined; readonly nuke?: Nuke; - /** Answers `device.add` and `device.remove`; optional for the tests that never enrol one. */ readonly enrollment?: Pick; /** * Builds the scoped command behind `simlock simctl` / `simlock adb` (ADR 0001, decision 7). @@ -821,11 +820,6 @@ export class Dispatcher { } }; - /** - * ADR 0022 §2: enrols the physical device the driver reads for `id`. A refusal and a busy device - * carry their reason as details, so a transport can show them; every other failure is - * classified as it is thrown. - */ #deviceAdd: Handler<"device.add"> = async (input) => { try { const { device, reenrolled } = await this.#requireEnrollment().add(input.platform, input.id); @@ -947,7 +941,6 @@ function killQuietly(handle: StreamingProcessHandle, signal: NodeJS.Signals): vo } } -/** The refusals of `device.add` and `device.remove` as the dispatcher's own, with their details. */ function describeEnrollmentError(error: unknown): unknown { if (error instanceof DeviceNotEnrollableError) { return new DispatchError("DEVICE_NOT_ENROLLABLE", error.message, { diff --git a/src/daemon/server.ts b/src/daemon/server.ts index fecb32ea4..0bdb670c9 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -159,7 +159,6 @@ export interface DaemonServerEngineOptions { readonly queue: QueueControl; readonly reaper: CleanupReaper; readonly healthMonitor?: LeaseHealthMonitor | undefined; - /** `device.add` and `device.remove`; absent in tests that never enrol a device. */ readonly enrollment?: DispatcherOptions["enrollment"]; readonly nuke?: Nuke; /** Builds the scoped command behind `simlock simctl` / `simlock adb`; absent in tests that never use them. */ diff --git a/src/gateway/worker-registry.ts b/src/gateway/worker-registry.ts index 4a6ec837f..fca12b434 100644 --- a/src/gateway/worker-registry.ts +++ b/src/gateway/worker-registry.ts @@ -45,7 +45,7 @@ export type WorkerViewSnapshot = Pick< /** A worker's device in the shape a grant carries it: what `GET /v1/leases/{id}` builds a lease * payload from. Kept beside a view, never on it, because a view is what `status.get` and - * `worker.list` show every agent, and `driverDeviceId` is only the lease holder's to see. */ + * `worker.list` show every agent, and a virtual device's `driverDeviceId` is only the lease holder's to see. */ export type WorkerGrantedDevice = z.infer; /** What one refresh over the uplink commits: the view's fields, and the same devices in the From a67f1230178f62f8f9a0a2017654cc675d5020d4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 10:29:20 +0200 Subject: [PATCH 12/22] test: kill surviving mutants of enrollment: messages, claims, parsing, dispatch, routes, client (#437) 174 alive -> unit tests added for each; one guard left as equivalent --- src/cli/index.test.ts | 93 +++++++++++++++ src/contract/errors.test.ts | 31 ++++- src/contract/operations.test.ts | 61 ++++++++++ src/core/fake-driver.test.ts | 69 +++++++++++ src/core/physical-enrollment.test.ts | 149 ++++++++++++++++++++++++ src/core/quarantine-coordinator.test.ts | 30 +++++ src/core/quarantine-coordinator.ts | 1 - src/core/registry.test.ts | 79 +++++++++++-- src/daemon/dispatcher.test.ts | 143 +++++++++++++++++++++++ src/daemon/server.test.ts | 29 +++++ src/gateway/dispatcher.test.ts | 31 +++++ src/http/app.test.ts | 74 ++++++++++++ src/simlock-client/client.test.ts | 38 ++++++ ui/src/views/worker-detail.test.tsx | 11 +- 14 files changed, 829 insertions(+), 10 deletions(-) diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 328a91d67..5c7ec17a3 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1474,6 +1474,99 @@ describe("CLI: device add and device remove (ADR 0022 §2)", () => { expect(JSON.parse(json.stdout)).toMatchObject({ driverDeviceId: "SER-1", platform: "android" }); }); + it.each([ + ["device", ["device"]], + ["device -h", ["device", "-h"]], + ["device add --help", ["device", "add", "--help"]], + ["device remove -h", ["device", "remove", "-h"]], + ])("prints the usage and contacts nobody for %s", async (_case, argv) => { + const result = await runDevice(argv); + + expect(result.code).toBe(0); + expect(result.connected).toBe(false); + expect(result.stdout).toBe( + "Usage: simlock device add --platform [--json]\n" + + " simlock device remove [--json]\n", + ); + }); + + it("says what is wrong with a usage error, and refuses --platform on remove", async () => { + const messages = async (argv: string[]) => { + const result = await runDevice(argv); + expect(result.code).toBe(2); + return (JSON.parse(result.stderr) as { error: { message: string } }).error.message; + }; + + expect(await messages(["device", "enroll", "X"])).toContain("Unknown device command: enroll"); + expect(await messages(["device", "add", "--platform", "ios"])).toContain( + "device add takes exactly one ", + ); + expect(await messages(["device", "remove", "A", "B"])).toContain( + "device remove takes exactly one ", + ); + expect(await messages(["device", "add", "--platform", "windows", "X"])).toContain( + "device add needs --platform ios or --platform android", + ); + await messages(["device", "remove", "--platform", "ios", "X"]); + }); + + it("closes the connection after an enrollment and after a removal", async () => { + const closed: string[] = []; + for (const argv of [ + ["device", "add", "--platform", "ios", "U"], + ["device", "remove", "U"], + ]) { + const output = outputCapture(); + await runCli( + argv, + output.environmentWith({ + connectAdmin: async () => ({ + ...fakeClient(), + close: () => { + closed.push(argv[1] ?? ""); + return Promise.resolve(); + }, + }), + }), + ); + } + + expect(closed).toEqual(["add", "remove"]); + }); + + it("says again for a re-enrollment, one user app in the singular, and the Android OS name", async () => { + const output = outputCapture(); + await runCli( + ["device", "add", "--platform", "android", "SER-1"], + output.environmentWith({ + connectAdmin: async () => ({ + ...fakeClient(), + addDevice: () => + Promise.resolve({ + appCount: 1, + class: "tablet" as const, + driverDeviceId: "SER-1", + id: "dev_1", + model: "Pixel Tablet", + osVersion: "15", + platform: "android" as const, + reenrolled: true, + }), + }), + }), + ); + + expect(output.stdout).toBe( + "Enrolled Pixel Tablet (Android 15) as SER-1 again, 1 user app recorded.\n", + ); + }); + + it("prints the operation's output for remove with --json", async () => { + const result = await runDevice(["device", "remove", "UDID-1", "--json"]); + + expect(JSON.parse(result.stdout)).toEqual({ driverDeviceId: "UDID-1", id: "UDID-1" }); + }); + it("prints one line for a removal, with no confirmation", async () => { const result = await runDevice(["device", "remove", "UDID-1"]); diff --git a/src/contract/errors.test.ts b/src/contract/errors.test.ts index c044c2e6f..dbf5286af 100644 --- a/src/contract/errors.test.ts +++ b/src/contract/errors.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { ERROR_TABLE, fromWireError, isSimlockError } from "./errors.js"; +import { + CODES_WITH_DECLARED_DETAILS, + ERROR_TABLE, + fromWireError, + isSimlockError, +} from "./errors.js"; describe("SimlockError", () => { it("narrows details by code", () => { @@ -56,4 +61,28 @@ describe("SimlockError", () => { it("isSimlockError rejects a plain Error", () => { expect(isSimlockError(new Error("boom"))).toBe(false); }); + + it("answers the three enrollment codes as domain errors: not enrollable 12 and 422, busy 11 and 409, unknown 12 and 404, each with declared details", () => { + expect(ERROR_TABLE.DEVICE_NOT_ENROLLABLE).toEqual({ + cliExitCode: 12, + code: "DEVICE_NOT_ENROLLABLE", + httpStatus: 422, + kind: "domain", + }); + expect(ERROR_TABLE.DEVICE_BUSY).toEqual({ + cliExitCode: 11, + code: "DEVICE_BUSY", + httpStatus: 409, + kind: "domain", + }); + expect(ERROR_TABLE.UNKNOWN_DEVICE).toEqual({ + cliExitCode: 12, + code: "UNKNOWN_DEVICE", + httpStatus: 404, + kind: "domain", + }); + for (const code of ["DEVICE_NOT_ENROLLABLE", "DEVICE_BUSY", "UNKNOWN_DEVICE"] as const) { + expect(CODES_WITH_DECLARED_DETAILS.has(code)).toBe(true); + } + }); }); diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index 9a831ed3e..2101109ff 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -1293,4 +1293,65 @@ describe("device.add and device.remove ids (ADR 0022 §2)", () => { ).toBe(false); expect(OPERATIONS["device.remove"].input.safeParse({ id: "A", yes: true }).success).toBe(false); }); + + it.each([ + ["a unit separator", "A\u001fB"], + ["a delete", "A\u007fB"], + ["a C1 control", "A\u0080B"], + ["the last C1 control", "A\u009fB"], + ["a no-break space", "A\u00a0B"], + ["a newline", "A\nB"], + ])("refuses an id with %s", (_case, id) => { + expect(OPERATIONS["device.remove"].input.safeParse({ id }).success).toBe(false); + }); + + it.each([ + ["a letter beyond ASCII", "caf\u00e9"], + ["a dash inside", "A-B"], + ["a tilde and an inverted exclamation mark", "A~\u00a1B"], + ])("takes an id with %s", (_case, id) => { + expect(OPERATIONS["device.remove"].input.safeParse({ id }).success).toBe(true); + }); + + it("says why an id is refused", () => { + const messages = (id: string) => + OPERATIONS["device.remove"].input + .safeParse({ id }) + .error?.issues.map((issue) => issue.message); + + expect(messages("A B")).toEqual(["must not contain whitespace or control characters"]); + expect(messages("-A")).toEqual(["must not start with -"]); + }); + + it("declares device.add and device.remove with their names, outputs and strict inputs", () => { + const output = { + appCount: 3, + class: "tablet", + driverDeviceId: "U", + id: "dev_1", + model: "iPad", + osVersion: "18", + platform: "ios", + reenrolled: true, + }; + + expect(OPERATIONS["device.add"].name).toBe("device.add"); + expect(OPERATIONS["device.remove"].name).toBe("device.remove"); + expect(OPERATIONS["device.add"].output.parse(output)).toEqual(output); + expect(OPERATIONS["device.add"].output.safeParse({ ...output, class: "watch" }).success).toBe( + false, + ); + expect(OPERATIONS["device.add"].output.safeParse({ ...output, appCount: -1 }).success).toBe( + false, + ); + expect(OPERATIONS["device.remove"].output.parse({ driverDeviceId: "U", id: "U" })).toEqual({ + driverDeviceId: "U", + id: "U", + }); + expect(OPERATIONS["device.add"].input.safeParse({ id: "A" }).success).toBe(false); + expect(OPERATIONS["device.add"].input.safeParse({ id: "A", platform: "windows" }).success).toBe( + false, + ); + expect(OPERATIONS["device.remove"].input.safeParse({}).success).toBe(false); + }); }); diff --git a/src/core/fake-driver.test.ts b/src/core/fake-driver.test.ts index bb324d1f4..a316fcfb5 100644 --- a/src/core/fake-driver.test.ts +++ b/src/core/fake-driver.test.ts @@ -240,4 +240,73 @@ describe("FakeDriver", () => { ).resolves.toMatchObject({ customModels: ["My Tablet"] }); expect(await new FakeDriver(options).listCatalog()).not.toHaveProperty("customModels"); }); + + it("reads a scripted physical device by any of its IDs, answering with the first as canonical and defaults for what is unset", async () => { + const driver = new FakeDriver({ + clock: new FakeClock(), + physicalDevices: [ + { apps: ["a"], class: "tablet", ids: ["UDID", "ALIAS"], model: "iPad", osVersion: "18.5" }, + { ids: ["PLAIN"] }, + ], + platform: "ios", + }); + + await expect(driver.inspectPhysical("ALIAS")).resolves.toEqual({ + apps: ["a"], + class: "tablet", + driverDeviceId: "UDID", + enrollable: true, + model: "iPad", + osVersion: "18.5", + }); + await expect(driver.inspectPhysical("PLAIN")).resolves.toEqual({ + apps: [], + class: "phone", + driverDeviceId: "PLAIN", + enrollable: true, + model: "Fake Phone", + osVersion: "1.0", + }); + }); + + it("refuses an ID no scripted device lists as not-connected, and a scripted refusal with its reason and message", async () => { + const driver = new FakeDriver({ + clock: new FakeClock(), + physicalDevices: [ + { ids: ["LOCKED"], refusal: { message: "locked: unlock it", reason: "locked" } }, + ], + platform: "ios", + }); + + await expect(driver.inspectPhysical("NOPE")).resolves.toEqual({ + enrollable: false, + message: "NOPE is not connected", + reason: "not-connected", + }); + await expect(driver.inspectPhysical("LOCKED")).resolves.toEqual({ + enrollable: false, + message: "locked: unlock it", + reason: "locked", + }); + await expect( + new FakeDriver({ clock: new FakeClock(), platform: "ios" }).inspectPhysical("X"), + ).resolves.toMatchObject({ reason: "not-connected" }); + }); + + it("reads the devices setPhysicalDevices replaced, not the ones its options named, and logs the call", async () => { + const driver = new FakeDriver({ + clock: new FakeClock(), + physicalDevices: [{ ids: ["OLD"] }], + platform: "ios", + }); + + driver.setPhysicalDevices([{ ids: ["NEW"] }]); + + await expect(driver.inspectPhysical("OLD")).resolves.toMatchObject({ enrollable: false }); + await expect(driver.inspectPhysical("NEW")).resolves.toMatchObject({ enrollable: true }); + expect(driver.calls.map((call) => [call.operation, call.arguments])).toEqual([ + ["inspectPhysical", ["OLD"]], + ["inspectPhysical", ["NEW"]], + ]); + }); }); diff --git a/src/core/physical-enrollment.test.ts b/src/core/physical-enrollment.test.ts index b5c1d6bf1..8977daced 100644 --- a/src/core/physical-enrollment.test.ts +++ b/src/core/physical-enrollment.test.ts @@ -418,4 +418,153 @@ describe("PhysicalEnrollment", () => { }); expect(live(harness)).toHaveLength(1); }); + + it("words a busy device as leased, reclaiming or being enrolled or removed", async () => { + const harness = await build(); + const { device } = await harness.core.enrollment.add("ios", UDID); + const claim = harness.core.claims.tryClaim(device.id, "reclaim"); + await expect(harness.core.enrollment.remove(UDID)).rejects.toThrow( + `${UDID} is being reclaimed: try again when it finishes`, + ); + claim?.release(); + const enrolling = harness.core.claims.tryClaim(device.id, "enrollment"); + await expect(harness.core.enrollment.remove(UDID)).rejects.toThrow( + `${UDID} is being enrolled or removed: try again in a moment`, + ); + enrolling?.release(); + await harness.registry.createLease({ + deviceId: device.id, + ownerId: "agent", + requesterId: "agent", + ttlDeadline: 61_000, + ttlMs: 60_000, + }); + await expect(harness.core.enrollment.add("ios", UDID)).rejects.toThrow( + `${UDID} is leased: release it first`, + ); + }); + + it("takes the enrollment claim of the device while its second inspection runs, and frees it after", async () => { + const harness = await build(); + const { device } = await harness.core.enrollment.add("ios", UDID); + const held = holdInspection(harness.driver, 2); + const waiting = harness.core.enrollment.add("ios", UDID); + await vi.waitFor(() => expect(held.reached).toHaveBeenCalled()); + + expect(harness.core.claims.claim(device.id)?.kind).toBe("enrollment"); + held.release(); + await waiting; + + expect(harness.core.claims.claim(device.id)).toBeUndefined(); + }); + + it("answers UNKNOWN_DEVICE, naming the ID, to a remove of an ID with no record and of a deleted one", async () => { + const harness = await build(); + + await expect(harness.core.enrollment.remove(UDID)).rejects.toMatchObject({ + id: UDID, + message: `${UDID} is not an enrolled device`, + name: "UnknownEnrolledDeviceError", + }); + await harness.core.enrollment.add("ios", UDID); + await harness.core.enrollment.remove(UDID); + await expect(harness.core.enrollment.remove(UDID)).rejects.toMatchObject({ + name: "UnknownEnrolledDeviceError", + }); + }); + + it("writes a new record, not a re-enrollment, for a device whose earlier record is deleted", async () => { + const harness = await build(); + const first = await harness.core.enrollment.add("ios", UDID); + await harness.core.enrollment.remove(UDID); + + const again = await harness.core.enrollment.add("ios", ALIAS); + + expect(again.reenrolled).toBe(false); + expect(again.device.id).not.toBe(first.device.id); + expect(live(harness)).toHaveLength(1); + expect(physicalRecords(harness)).toHaveLength(2); + }); + + it("names the device in its error when the second read names another device or a read fails", async () => { + const harness = await build(); + await harness.core.enrollment.add("ios", UDID); + const original = harness.driver.inspectPhysical.bind(harness.driver); + let calls = 0; + vi.spyOn(harness.driver, "inspectPhysical").mockImplementation(async (id) => { + calls += 1; + const read = await original(id); + return calls === 1 || !read.enrollable ? read : { ...read, driverDeviceId: "SOMEONE-ELSE" }; + }); + + await expect(harness.core.enrollment.add("ios", ALIAS)).rejects.toThrow( + `${UDID} was read again as SOMEONE-ELSE`, + ); + vi.spyOn(harness.driver, "inspectPhysical").mockRejectedValue(new Error("usbmuxd went away")); + await expect(harness.core.enrollment.add("ios", ALIAS)).rejects.toThrow( + `Reading ${ALIAS} failed: usbmuxd went away`, + ); + vi.spyOn(harness.driver, "inspectPhysical").mockRejectedValue("a bare string"); + await expect(harness.core.enrollment.add("ios", ALIAS)).rejects.toThrow( + `Reading ${ALIAS} failed: a bare string`, + ); + }); + + it("does not mistake a simulator with the canonical ID for the enrolled record, and remove forgets only the physical one", async () => { + const harness = await build(); + const provisioned = await harness.driver.provision({ + model: "iPhone 16", + osVersion: "1", + physical: false, + platform: "ios", + }); + const registered = await harness.registry.registerDevice({ + driverData: provisioned.driverData, + driverDeviceId: UDID, + provisionDuration: 0, + spec: { model: "iPhone 16", osVersion: "1", physical: false, platform: "ios" }, + }); + await harness.registry.transitionDevice( + registered.id, + "ready", + { event: "device.ready", payload: { bootDuration: 0, deviceId: registered.id } }, + { address: "emulator-5554" }, + ); + await expect(harness.core.enrollment.add("ios", ALIAS)).resolves.toMatchObject({ + reenrolled: false, + }); + await expect(harness.core.enrollment.add("ios", UDID)).rejects.toMatchObject({ + reason: "virtual-device", + message: `${UDID} is a simulator or emulator Simlock manages`, + }); + await expect(harness.core.enrollment.remove(UDID)).resolves.toMatchObject({ + physical: true, + state: "deleted", + }); + expect(harness.registry.snapshot.virtualDevices[0]?.state).toBe("ready"); + }); + + it("enrols a device whose ID a deleted simulator once had", async () => { + const harness = await build(); + const provisioned = await harness.driver.provision({ + model: "iPhone 16", + osVersion: "1", + physical: false, + platform: "ios", + }); + const registered = await harness.registry.registerDevice({ + driverData: provisioned.driverData, + driverDeviceId: UDID, + provisionDuration: 0, + spec: { model: "iPhone 16", osVersion: "1", physical: false, platform: "ios" }, + }); + await harness.registry.transitionDevice(registered.id, "deleted", { + event: "device.deleted", + payload: { deviceId: registered.id, initiator: "test" }, + }); + + await expect(harness.core.enrollment.add("ios", UDID)).resolves.toMatchObject({ + reenrolled: false, + }); + }); }); diff --git a/src/core/quarantine-coordinator.test.ts b/src/core/quarantine-coordinator.test.ts index 87bf5a6e7..8889dfb6d 100644 --- a/src/core/quarantine-coordinator.test.ts +++ b/src/core/quarantine-coordinator.test.ts @@ -846,4 +846,34 @@ describe("QuarantineCoordinator retry claim (ADR 0022 §2)", () => { expect(eventsOf(harness.bus).at(-1)?.event).toBe("device.quarantine-abandoned"); expect(claimedAt).toEqual([false, false]); }); + + it("has freed the claim before it emits when a retry gives up and cannot destroy the device", async () => { + const clock = new FakeClock(1_000); + const driver = new FakeDriver({ clock, platform: "ios" }); + driver.failOn("reclaim", 1, new Error("still stuck")); + driver.failOn("reclaim", 2, new Error("still stuck")); + driver.failOn("destroy", 1, new Error("cannot destroy")); + const harness = await stalled({ clock, driver }); + const claimedAt: boolean[] = []; + harness.bus.subscribe("device.quarantine-stranded", () => + claimedAt.push(harness.claims.isClaimed(harness.target.id)), + ); + + harness.clock.advance(retryConfig.retryBackoffMs); + await flush(); + harness.clock.advance(2_000); + await flush(); + + expect(claimedAt).toEqual([false]); + }); + + it("frees the claim when a registry write throws during the retry", async () => { + const harness = await stalled(); + vi.spyOn(harness.registry, "recoverFromQuarantine").mockRejectedValue(new Error("disk full")); + + harness.clock.advance(retryConfig.retryBackoffMs); + await flush(); + + expect(harness.claims.isClaimed(harness.target.id)).toBe(false); + }); }); diff --git a/src/core/quarantine-coordinator.ts b/src/core/quarantine-coordinator.ts index 42e6fd733..c413d72e8 100644 --- a/src/core/quarantine-coordinator.ts +++ b/src/core/quarantine-coordinator.ts @@ -296,7 +296,6 @@ export class QuarantineCoordinator { await this.options.decisions.run(() => this.options.registry.recordQuarantineRetryFailure(device.id, attempts, nextRetryAt), ); - claim.release(); this.#arm(device.id, nextRetryAt); } diff --git a/src/core/registry.test.ts b/src/core/registry.test.ts index 7249bcabd..50b0cc909 100644 --- a/src/core/registry.test.ts +++ b/src/core/registry.test.ts @@ -2699,8 +2699,12 @@ describe("Registry: device.deleted carries physical, and enrollment writes (ADR statePath, }); const deleted: unknown[] = []; - bus.subscribe("device.deleted", (envelope) => deleted.push(envelope.payload)); - return { bus, deleted, registry }; + const modules: string[] = []; + bus.subscribe("device.deleted", (envelope) => { + deleted.push(envelope.payload); + modules.push(envelope.module); + }); + return { bus, deleted, modules, registry }; } async function readyVirtual(registry: Registry, driverDeviceId: string) { @@ -2727,7 +2731,7 @@ describe("Registry: device.deleted carries physical, and enrollment writes (ADR } as const; it("carries physical false from deleteQuarantined, markDeviceMissing, endLeaseAndMarkDeviceMissing and a transitionDevice of a virtual record", async () => { - const { deleted, registry } = await load(); + const { deleted, modules, registry } = await load(); const quarantined = await readyVirtual(registry, "d1"); const lease = await registry.createLease({ deviceId: quarantined.id, @@ -2766,24 +2770,28 @@ describe("Registry: device.deleted carries physical, and enrollment writes (ADR { deviceId: leased.id, initiator: "doctor", physical: false }, { deviceId: plain.id, initiator: "idle", physical: false }, ]); + expect(modules).toEqual(["registry", "registry", "registry", "registry"]); }); it("carries physical true and initiator operator from unenroll, after the commit", async () => { - const { deleted, registry } = await load(); + const { deleted, modules, registry } = await load(); const device = await registry.enrollPhysical(input); await registry.unenrollPhysical(device.id); expect(deleted).toEqual([{ deviceId: device.id, initiator: "operator", physical: true }]); + expect(modules).toEqual(["registry"]); expect(registry.snapshot.devices[0]).toMatchObject({ id: device.id, state: "deleted" }); }); it("emits device.enrolled after the commit, reenrolled false for a new record and true for a rewrite", async () => { const { bus, registry } = await load(); const seen: unknown[] = []; - bus.subscribe("device.enrolled", (envelope) => - seen.push({ payload: envelope.payload, stored: registry.snapshot.devices.length }), - ); + const modules: string[] = []; + bus.subscribe("device.enrolled", (envelope) => { + seen.push({ payload: envelope.payload, stored: registry.snapshot.devices.length }); + modules.push(envelope.module); + }); const device = await registry.enrollPhysical(input); await registry.reenrollPhysical(device.id, { ...input, apps: ["a", "b"], model: "iPhone 16" }); @@ -2807,6 +2815,57 @@ describe("Registry: device.deleted carries physical, and enrollment writes (ADR stored: 1, }, ]); + expect(modules).toEqual(["registry", "registry"]); + expect(device.id).toBe("dev_r1"); + }); + + it("writes the record a new enrollment makes: ready now, the driver's read, no apps beyond the list, and a rewrite keeps the ID and replaces the read", async () => { + const { registry } = await load(); + + const device = await registry.enrollPhysical(input); + const rewritten = await registry.reenrollPhysical(device.id, { + ...input, + apps: [], + class: "tablet", + osVersion: "18.5", + }); + + expect(device).toEqual({ + createdAt: 1_000, + driverData: {}, + driverDeviceId: input.driverDeviceId, + enrolledApps: ["com.example.one"], + id: "dev_r1", + physical: true, + readyAt: 1_000, + spec: { + class: "phone", + model: "iPhone 15 Pro", + osVersion: "18.4", + physical: true, + platform: "ios", + }, + state: "ready", + }); + expect(rewritten).toMatchObject({ + enrolledApps: [], + id: "dev_r1", + spec: { class: "tablet", osVersion: "18.5", physical: true, platform: "ios" }, + }); + }); + + it("keeps every other record when it rewrites or forgets one", async () => { + const { registry } = await load(); + const other = await registry.enrollPhysical({ ...input, driverDeviceId: "OTHER" }); + const device = await registry.enrollPhysical(input); + + await registry.reenrollPhysical(device.id, { ...input, model: "iPhone 16" }); + await registry.unenrollPhysical(device.id); + + expect(registry.snapshot.devices.map((record) => [record.id, record.state])).toEqual([ + [other.id, "ready"], + [device.id, "deleted"], + ]); }); it("refuses to re-enrol or unenrol a leased physical record, and a virtual one", async () => { @@ -2824,5 +2883,11 @@ describe("Registry: device.deleted carries physical, and enrollment writes (ADR await expect(registry.reenrollPhysical(device.id, input)).rejects.toThrow(RegistryEventError); await expect(registry.unenrollPhysical(device.id)).rejects.toThrow(RegistryEventError); await expect(registry.unenrollPhysical(virtual.id)).rejects.toThrow(RegistryEventError); + await expect(registry.reenrollPhysical(device.id, input)).rejects.toThrow( + `Cannot re-enrol device: ${device.id}`, + ); + await expect(registry.unenrollPhysical(device.id)).rejects.toThrow( + `Cannot unenrol device: ${device.id}`, + ); }); }); diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 2da44d4eb..10dbfa436 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -170,6 +170,9 @@ async function buildDispatcher( * pay for wiring one -- since a real `Nuke` needs this function's own `engine`/`registry`, * it is built here (rather than passed in) when a test opts in. */ readonly includeNuke?: boolean; + /** Wires `device.add` and `device.remove` to the engine's real `PhysicalEnrollment`; off by + * default, like the option it feeds. */ + readonly includeEnrollment?: boolean; /** Gives the fake driver a `simlock ` wrapper so `driver.passthrough` has something * to route to; off by default so no other test's catalog output changes shape. */ readonly passthroughTool?: string; @@ -321,6 +324,7 @@ async function buildDispatcher( instanceId: "instance-1", leases: engine, ...(overrides.includeNuke === true ? { nuke: new Nuke({ executor: engine, registry }) } : {}), + ...(overrides.includeEnrollment === true ? { enrollment: engine.core.enrollment } : {}), passthrough: resolvePassthroughOverride(engine, overrides.passthroughOverride), ...(overrides.processRunner === undefined ? {} : { processRunner: overrides.processRunner }), execEnv: { PATH: "/usr/bin" }, @@ -3300,6 +3304,145 @@ function testConfig( }; } +describe("Dispatcher: device.add and device.remove", () => { + const admin = () => session({ principal: "tok_operator", role: "admin" }); + const UDID = "00008110-001A2B3C4D5E"; + + async function withEnrollment() { + return buildDispatcher({ + driverOptions: { + physicalDevices: [ + { + apps: ["a", "b"], + class: "tablet", + ids: [UDID, "ALIAS"], + model: "iPad", + osVersion: "18.5", + }, + { ids: ["LOCKED"], refusal: { message: "locked: unlock it", reason: "locked" } }, + ], + }, + includeEnrollment: true, + }); + } + + it("enrolls a device, answering with the registry ID, the canonical ID and what was read, and removes it by its canonical ID", async () => { + const { dispatcher, registry } = await withEnrollment(); + + const added = await dispatcher.dispatch( + "device.add", + { id: "ALIAS", platform: "ios" }, + admin(), + ); + const removed = await dispatcher.dispatch("device.remove", { id: UDID }, admin()); + + expect(added).toEqual({ + appCount: 2, + class: "tablet", + driverDeviceId: UDID, + id: registry.snapshot.devices[0]?.id, + model: "iPad", + osVersion: "18.5", + platform: "ios", + reenrolled: false, + }); + expect(removed).toEqual({ driverDeviceId: UDID, id: UDID }); + expect(registry.snapshot.devices[0]?.state).toBe("deleted"); + }); + + it("answers a refusal as DEVICE_NOT_ENROLLABLE with the ID as typed, the platform, the reason and the driver's message", async () => { + const { dispatcher } = await withEnrollment(); + + const refusal = dispatcher.dispatch("device.add", { id: "LOCKED", platform: "ios" }, admin()); + + await expect(refusal).rejects.toMatchObject({ + code: "DEVICE_NOT_ENROLLABLE", + details: { id: "LOCKED", platform: "ios", reason: "locked" }, + message: "locked: unlock it", + }); + await expect(refusal).rejects.not.toHaveProperty("details.canonicalId"); + }); + + it("carries the canonical ID of a refusal of the second read", async () => { + const { dispatcher, driver } = await withEnrollment(); + await dispatcher.dispatch("device.add", { id: UDID, platform: "ios" }, admin()); + const original = driver.inspectPhysical.bind(driver); + let calls = 0; + vi.spyOn(driver, "inspectPhysical").mockImplementation(async (id) => { + calls += 1; + return calls === 1 + ? original(id) + : { enrollable: false, message: "locked: unlock it", reason: "locked" }; + }); + + await expect( + dispatcher.dispatch("device.add", { id: "ALIAS", platform: "ios" }, admin()), + ).rejects.toMatchObject({ + code: "DEVICE_NOT_ENROLLABLE", + details: { canonicalId: UDID, id: "ALIAS", platform: "ios", reason: "locked" }, + }); + }); + + it("answers a leased device as DEVICE_BUSY with its ID and reason, and an unknown one as UNKNOWN_DEVICE", async () => { + const { dispatcher, registry } = await withEnrollment(); + const { id } = await dispatcher.dispatch("device.add", { id: UDID, platform: "ios" }, admin()); + await registry.createLease({ + deviceId: id, + ownerId: "agent", + requesterId: "agent", + ttlDeadline: 61_000, + ttlMs: 60_000, + }); + + await expect(dispatcher.dispatch("device.remove", { id: UDID }, admin())).rejects.toMatchObject( + { + code: "DEVICE_BUSY", + details: { id: UDID, reason: "leased" }, + message: `${UDID} is leased: release it first`, + }, + ); + await expect( + dispatcher.dispatch("device.remove", { id: "NOPE" }, admin()), + ).rejects.toMatchObject({ + code: "UNKNOWN_DEVICE", + details: { id: "NOPE" }, + message: "NOPE is not an enrolled device", + }); + }); + + it("passes a driver that throws on as the error it is, and answers INTERNAL on a daemon with no enrollment", async () => { + const { dispatcher, driver } = await withEnrollment(); + driver.failOn("inspectPhysical", 1, new Error("usbmuxd went away")); + const without = await buildDispatcher(); + + const failure: unknown = await dispatcher + .dispatch("device.add", { id: UDID, platform: "ios" }, admin()) + .catch((error: unknown) => error); + expect(failure).toMatchObject({ + message: `Reading ${UDID} failed: usbmuxd went away`, + name: "PhysicalInspectionError", + }); + await expect( + without.dispatcher.dispatch("device.add", { id: UDID, platform: "ios" }, admin()), + ).rejects.toMatchObject({ code: "INTERNAL", message: "Device enrollment is unavailable" }); + await expect( + without.dispatcher.dispatch("device.remove", { id: UDID }, admin()), + ).rejects.toMatchObject({ code: "INTERNAL", message: "Device enrollment is unavailable" }); + }); + + it("refuses an agent session with FORBIDDEN and never asks the driver", async () => { + const { dispatcher, driver } = await withEnrollment(); + + await expect( + dispatcher.dispatch("device.add", { id: UDID, platform: "ios" }, session()), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + dispatcher.dispatch("device.remove", { id: UDID }, session()), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(driver.calls.filter((call) => call.operation === "inspectPhysical")).toEqual([]); + }); +}); + describe("Dispatcher: component.remove", () => { const admin = (overrides: Partial = {}) => session({ principal: "tok_operator", role: "admin", ...overrides }); diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index b22f106c3..f571ce56e 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -655,6 +655,32 @@ describe("DaemonServer", () => { ]); }); + it("answers device.add and device.remove over the socket from the daemon's enrollment", async () => { + const clock = new FakeClock(1_000); + const driver = new FakeDriver({ + clock, + physicalDevices: [ + { apps: ["a"], ids: ["UDID-1"], model: "iPhone 15 Pro", osVersion: "18.4" }, + ], + platform: "ios", + }); + const harness = await createHarness({ clock, driver, includeEnrollment: true }); + const client = await createClient(harness.socketPath); + await hello(client); + + const added = await client.request("device.add", { id: "UDID-1", platform: "ios" }, "add-1"); + const removed = await client.request("device.remove", { id: "UDID-1" }, "remove-1"); + + expect(added).toMatchObject({ + ok: true, + payload: { appCount: 1, driverDeviceId: "UDID-1", platform: "ios", reenrolled: false }, + }); + expect(removed).toMatchObject({ + ok: true, + payload: { driverDeviceId: "UDID-1", id: "UDID-1" }, + }); + }); + it("routes two commands on one connection by frame id, and pushes to no other connection", async () => { // ADR 0005 §19a keys `output` on the request's frame id for the same reason `progress` is // keyed on it: one connection may have several calls in flight. And a push is a *reply* to @@ -2913,6 +2939,8 @@ async function createHarness( readonly host?: ConnectionHost; /** Overrides the protocol range this daemon advertises, to stand in for an older worker. */ readonly protocolVersion?: ProtocolRange; + /** Wires `device.add` and `device.remove` to the engine's enrollment; absent by default. */ + readonly includeEnrollment?: boolean; } = {}, ) { const directory = @@ -2978,6 +3006,7 @@ async function createHarness( warmPool: engine, deviceModes: engine, catalog: engine, + ...(options.includeEnrollment === true ? { enrollment: engine.core.enrollment } : {}), instanceId: "instance-test", clock, components: wiring.components, diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index 2b3e86cdf..ebdeb9b65 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -856,6 +856,37 @@ describe("GatewayDispatcher", () => { }); }); + describe("device.add and device.remove", () => { + it("answer UNSUPPORTED_IN_GATEWAY_MODE to an admin session, saying to run the command on the worker the device is plugged into", async () => { + const { dispatcher } = harness(); + + await expect( + dispatcher.dispatch( + "device.add", + { id: "00008110-001A2B3C4D5E", platform: "ios" }, + session({ role: "admin" }), + ), + ).rejects.toMatchObject({ + code: "UNSUPPORTED_IN_GATEWAY_MODE", + details: { operation: "device.add" }, + message: + "device.add enrols a device plugged into one machine; run it on the worker the device is plugged into", + }); + await expect( + dispatcher.dispatch( + "device.remove", + { id: "00008110-001A2B3C4D5E" }, + session({ role: "admin" }), + ), + ).rejects.toMatchObject({ + code: "UNSUPPORTED_IN_GATEWAY_MODE", + details: { operation: "device.remove" }, + message: + "device.remove unenrols a device plugged into one machine; run it on the worker the device is plugged into", + }); + }); + }); + describe("lease lifecycle and device.exec (#118)", () => { it("forwards lease.request through the fleet coordinator and answers UNKNOWN_LEASE for renew/release/exec against an id it never issued", async () => { const { dispatcher } = harness(); diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 3a5181904..3dbe14747 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -327,6 +327,80 @@ describe("GET /v1/components", () => { }); }); +describe("POST /v1/devices and DELETE /v1/devices/{id}", () => { + const enrolled = { + appCount: 2, + class: "phone", + driverDeviceId: "00008110-001A2B3C4D5E", + id: "dev_1", + model: "iPhone 15 Pro", + osVersion: "18.4", + platform: "ios", + reenrolled: false, + }; + + it("dispatches device.add with the body's platform and id, and answers its result as JSON", async () => { + const { app, dispatcher } = buildHarness(); + const seen: unknown[] = []; + dispatcher.handlers["device.add"] = (input) => { + seen.push(input); + return enrolled; + }; + + const response = await app.request("/v1/devices", { + body: JSON.stringify({ id: "00008110-001A2B3C4D5E", platform: "ios" }), + headers: { ...operatorAuth, "content-type": "application/json" }, + method: "POST", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toMatch(/application\/json/); + expect(await response.json()).toEqual(enrolled); + expect(seen).toEqual([{ id: "00008110-001A2B3C4D5E", platform: "ios" }]); + }); + + it.each([ + ["a field it does not declare", { id: "A", platform: "ios", worker: "w1" }], + ["no platform", { id: "A" }], + ["an id that is not a string", { id: 7, platform: "ios" }], + ])("400s a body with %s, before the device is read", async (_case, body) => { + const { app, dispatcher } = buildHarness(); + dispatcher.handlers["device.add"] = () => { + throw new Error("should not be called"); + }; + + const response = await app.request("/v1/devices", { + body: JSON.stringify(body), + headers: { ...operatorAuth, "content-type": "application/json" }, + method: "POST", + }); + + expect(response.status).toBe(400); + expect(((await response.json()) as { error: { code: string } }).error.code).toBe("BAD_REQUEST"); + }); + + it("dispatches device.remove with the path's id, and answers its result as JSON", async () => { + const { app, dispatcher } = buildHarness(); + const seen: unknown[] = []; + dispatcher.handlers["device.remove"] = (input) => { + seen.push(input); + return { driverDeviceId: "00008110-001A2B3C4D5E", id: "00008110-001A2B3C4D5E" }; + }; + + const response = await app.request("/v1/devices/00008110-001A2B3C4D5E", { + headers: operatorAuth, + method: "DELETE", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + driverDeviceId: "00008110-001A2B3C4D5E", + id: "00008110-001A2B3C4D5E", + }); + expect(seen).toEqual([{ id: "00008110-001A2B3C4D5E" }]); + }); +}); + describe("DELETE /v1/components/{platform}/{version}", () => { it("dispatches component.remove with the path's platform and version, and answers its result as JSON", async () => { const { app, dispatcher } = buildHarness(); diff --git a/src/simlock-client/client.test.ts b/src/simlock-client/client.test.ts index c25e23e7d..40b581f08 100644 --- a/src/simlock-client/client.test.ts +++ b/src/simlock-client/client.test.ts @@ -71,6 +71,12 @@ describe("connectSimlock: handshake", () => { await expect(client.runCleanup()).rejects.toMatchObject({ code: "PROTOCOL_VERSION_UNSUPPORTED", }); + await expect(client.addDevice({ id: "UDID-1", platform: "ios" })).rejects.toMatchObject({ + code: "PROTOCOL_VERSION_UNSUPPORTED", + }); + await expect(client.removeDevice({ id: "UDID-1" })).rejects.toMatchObject({ + code: "PROTOCOL_VERSION_UNSUPPORTED", + }); expect(connection.sent).toHaveLength(before); const stopPromise = client.stopDaemon(); @@ -220,6 +226,38 @@ describe("connectSimlock: handshake", () => { await expect(callPromise).resolves.toEqual(answer); }); + it("enrolls a device from an admin client with device.add and removes it with device.remove, sending the input and returning the daemon's answer", async () => { + const connection = new ScriptedConnection(); + const connectPromise = connectSimlockAdmin({ connection, credential: "operator-secret" }); + await flushMicrotasks(); + completeHello(connection, { role: "admin" }); + const client = await connectPromise; + const enrolled = { + appCount: 2, + class: "phone", + driverDeviceId: "UDID-1", + id: "dev_1", + model: "iPhone 15 Pro", + osVersion: "18.4", + platform: "ios", + reenrolled: false, + }; + + const addPromise = client.addDevice({ id: "ALIAS", platform: "ios" }); + await flushMicrotasks(); + const addCall = connection.lastSentOf("device.add")!; + expect(addCall.payload).toEqual({ id: "ALIAS", platform: "ios" }); + connection.reply(addCall.id, enrolled); + await expect(addPromise).resolves.toEqual(enrolled); + + const removePromise = client.removeDevice({ id: "UDID-1" }); + await flushMicrotasks(); + const removeCall = connection.lastSentOf("device.remove")!; + expect(removeCall.payload).toEqual({ id: "UDID-1" }); + connection.reply(removeCall.id, { driverDeviceId: "UDID-1", id: "UDID-1" }); + await expect(removePromise).resolves.toEqual({ driverDeviceId: "UDID-1", id: "UDID-1" }); + }); + it("wraps a malformed daemon response instead of throwing a raw parse failure", async () => { const connection = new ScriptedConnection(); const connectPromise = connectSimlock({ connection }); diff --git a/ui/src/views/worker-detail.test.tsx b/ui/src/views/worker-detail.test.tsx index a1f881cd8..9869cf693 100644 --- a/ui/src/views/worker-detail.test.tsx +++ b/ui/src/views/worker-detail.test.tsx @@ -94,7 +94,7 @@ describe("the page of a worker that has finished starting", () => { expect(shown).not.toContain("appear once startup finishes"); }); - it("marks a simulator Virtual and an enrolled device Physical, and shows a dash for the physical device's mode and image tag", () => { + it("marks a simulator Virtual and an enrolled device Physical, and shows a dash for the physical device's mode and image tag and for a simulator with no image tag", () => { const shown = detail({ ...RUNNING, devices: [ @@ -112,6 +112,14 @@ describe("the page of a worker that has finished starting", () => { }, state: "ready", }, + { + physical: false, + id: "dev_ios", + mode: "full", + servesDefaultMode: true, + spec: { model: "iPhone 16", osVersion: "26.5", physical: false, platform: "ios" }, + state: "ready", + }, { class: "phone", driverDeviceId: "00008110-001A2B3C4D5E", @@ -125,6 +133,7 @@ describe("the page of a worker that has finished starting", () => { }); expect(shown).toContain("Android Virtual Pixel 9 35 full google_apis"); + expect(shown).toContain("iOS Virtual iPhone 16 26.5 full —"); expect(shown).toContain("iOS Physical iPhone 15 Pro 18.4 — —"); }); From daf7892de0e37044ca175633c6242e714448c729 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 10:35:08 +0200 Subject: [PATCH 13/22] test: the other-platform refusal names the platform (#437) 21 alive -> re-run without the branch's cached mutant results --- src/core/physical-enrollment.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/physical-enrollment.test.ts b/src/core/physical-enrollment.test.ts index 8977daced..8370dedd1 100644 --- a/src/core/physical-enrollment.test.ts +++ b/src/core/physical-enrollment.test.ts @@ -411,6 +411,9 @@ describe("PhysicalEnrollment", () => { }); await second.enrollment.add("ios", UDID); + await expect(second.enrollment.add("android", UDID)).rejects.toThrow( + `${UDID} is enrolled as a ios device`, + ); await expect(second.enrollment.add("android", UDID)).rejects.toMatchObject({ canonicalId: UDID, name: "DeviceNotEnrollableError", From 9300c64af1518b7ba76f4a642253d912c4b5ce67 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 11:03:55 +0200 Subject: [PATCH 14/22] fix: device remove answers the registry ID; unenrol and re-enrol go through transition; tests prove their titles; docs name the busy operations (#437) --- docs/CLI.md | 15 +++++--- docs/HTTP-API.md | 8 ++-- docs/internal/COMPONENTS.md | 2 +- e2e/physical-enrollment.test.ts | 23 ++++++++++-- src/core/doctor.test.ts | 2 +- src/core/driver.test.ts | 38 ++++++++++++------- src/core/physical-enrollment.test.ts | 8 ++-- src/core/physical-enrollment.ts | 4 +- src/core/quarantine-coordinator.test.ts | 49 ++++++++++++++----------- src/core/registry.test.ts | 3 ++ src/core/registry.ts | 35 +++++++----------- src/daemon/dispatcher.test.ts | 24 +----------- src/daemon/dispatcher.ts | 14 +------ src/daemon/server.test.ts | 27 +------------- src/leasing/create-leasing.test.ts | 11 ++++-- 15 files changed, 118 insertions(+), 145 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index ab32f8823..46fe8a165 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -73,7 +73,7 @@ command starts it again) to bring the platform up. | 10 | `EXEC_TIMEOUT` | a `simctl`/`adb` command run through `device.exec` outlived `exec.timeoutMs` and was killed | | 10 | `DOWNLOAD_TIMEOUT` | a runtime download, including the time spent waiting for another download on the same platform, outlived `downloads.timeoutMs` | | 11 | `NO_CAPACITY` | capacity reached and `--no-wait` was set, or, through a gateway, no worker that takes requests can serve the request | -| 11 | `DEVICE_BUSY` | `device add` or `device remove` of a device that is leased, being reclaimed or being enrolled or removed by another command; try again once that ends | +| 11 | `DEVICE_BUSY` | `device add` or `device remove` of a device that is leased, being reclaimed or held by another operation, such as an enrolment or a quarantine retry; try again once that ends | | 11 | `COMPONENT_BUSY` | `component remove` while a component install or removal runs or waits on that platform; try again once it ends | | 12 | `NO_DRIVER` | no driver registered for the requested platform | | 12 | `RUNTIME_MISSING` | runtime not installed and no `--allow-download`, or no installed image of the `--image-tag` asked for | @@ -911,10 +911,12 @@ install ` resolve their path on the **worker's** filesystem, so the artifact has to be there already (a shared volume, a CI checkout on that machine). -**Three commands refuse outright**, with `UNSUPPORTED_IN_GATEWAY_MODE` (exit -2): `simlock nuke`, `simlock cleanup`, and `simlock doctor`. Each acts on one -machine's devices as a whole, and in v1 they stay per-worker, direct -operations — run them against that worker's own daemon. A fleet-wide +**Five commands refuse outright**, with `UNSUPPORTED_IN_GATEWAY_MODE` (exit +2): `simlock nuke`, `simlock cleanup`, `simlock doctor`, `simlock device add` +and `simlock device remove`. The first three act on one +machine's devices as a whole, and the last two name a device plugged into one +worker; in v1 all of them stay per-worker, direct operations — run them +against that worker's own daemon. A fleet-wide destructive command from one endpoint is not something v1 offers. `driver.passthrough`, the operation behind the *local* form of `simlock simctl` / `simlock adb`, answers the same way for the same reason: a @@ -1806,7 +1808,8 @@ add again gets a new record. `enrolled-on-other-platform`. The message is the driver's own, and says what to do. - `DEVICE_BUSY` (exit 11): the device is `leased`, is `reclaiming`, or - another `device add` or `device remove` holds it (`busy`). + another operation holds it, such as a `device add` or a quarantine retry + (`busy`). - `NO_DRIVER` (exit 12): this machine has no driver for the platform. `device remove` takes the canonical ID, which `simlock status --json` and diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 32974a8ea..b92b6e723 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -1080,7 +1080,7 @@ registry id and answers `reenrolled: true`. carries `id` (as sent), `platform`, `reason` and, when known, `canonicalId`. - `409 DEVICE_BUSY` — the device is leased, reclaiming or held by another - enrollment. The body carries `id` and `reason` (`leased`, `reclaiming` or + operation. The body carries `id` and `reason` (`leased`, `reclaiming` or `busy`). - `422 NO_DRIVER` — no driver for the platform. - `403 FORBIDDEN` — an `agent` token. @@ -1096,11 +1096,13 @@ Role: `operator`. Forgets an enrolled phone or tablet, the same operation as `simlock device remove`. `{id}` is the canonical ID (`driverDeviceId`), URL-encoded. Nothing is sent to the device. -→ `200 { "id": "00008110-001A2B3C4D5E", "driverDeviceId": "00008110-001A2B3C4D5E" }` +→ `200 { "id": "dev_8f2a", "driverDeviceId": "00008110-001A2B3C4D5E" }` + +`id` is the registry id, as `POST /v1/devices` answers it. - `404 UNKNOWN_DEVICE` — no enrolled device has that ID. - `409 DEVICE_BUSY` — the device is leased, reclaiming or held by another - enrollment. + operation. - `403 FORBIDDEN` — an `agent` token. - `501 UNSUPPORTED_IN_GATEWAY_MODE` — the daemon is a gateway. diff --git a/docs/internal/COMPONENTS.md b/docs/internal/COMPONENTS.md index 5a3cfefbb..d06e9e8cd 100644 --- a/docs/internal/COMPONENTS.md +++ b/docs/internal/COMPONENTS.md @@ -107,7 +107,7 @@ modules with. | Component | Where | Owns | Does not | |---|---|---|---| -| `Driver` interface | `src/core/driver.ts` | The verbs the core speaks: `resolveSpec`, `provision`, `makeReady`, `reclaim`, `shutdown`, `destroy`, `estimate`, `listManaged`, `listCatalog`, component install and removal, passthrough, lease environment. | Carry platform concepts: addresses and driver data are opaque to the core. | +| `Driver` interface | `src/core/driver.ts` | The verbs the core speaks: `resolveSpec`, `provision`, `makeReady`, `reclaim`, `shutdown`, `destroy`, `estimate`, `inspectPhysical`, `listManaged`, `listCatalog`, component install and removal, passthrough, lease environment. | Carry platform concepts: addresses and driver data are opaque to the core. | | iOS driver | `src/drivers/ios/` | `IosSimctlDriver`: every `simctl` call scoped to the owned device set, erase as the reclaim, the slim pass inside `makeReady`, the label list, runtime install, prerequisites. | Decide the default mode or anything about leases. | | Android driver | `src/drivers/android/` | `AndroidDriver`: AVDs in the owned AVD home, Simlock's own adb server (`AdbServerSupervisor`, `AdbRegistrar`), the clean-baseline snapshot as the reclaim, device profiles, system-image install, prerequisites. | Same as iOS. | | Installer process | `src/drivers/installer-process.ts` | Running a platform's installer command with streamed progress. | Decide whether to install. | diff --git a/e2e/physical-enrollment.test.ts b/e2e/physical-enrollment.test.ts index 278b22914..35201e18f 100644 --- a/e2e/physical-enrollment.test.ts +++ b/e2e/physical-enrollment.test.ts @@ -1,3 +1,6 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import type { FakeDriverRefusalReason, ScriptedPhysicalDevice } from "./fake-driver/types.js"; @@ -287,13 +290,19 @@ describe("simlock device add", () => { ); it("of an ID no scripted device lists answers DEVICE_NOT_ENROLLABLE not-connected", async () => { - const env = await startDaemon({ ios: { physicalDevices: [IPHONE] } }); + const daemon = await httpDaemon({ ios: { physicalDevices: [IPHONE] } }); - const result = await env.cli(["device", "add", "--platform", "ios", "NOT-PLUGGED-IN"]); + const result = await daemon.env.cli(["device", "add", "--platform", "ios", "NOT-PLUGGED-IN"]); + const http = await postDevice(daemon, { id: "NOT-PLUGGED-IN", platform: "ios" }); expect(result.code, result.stderr).toBe(12); expect(result.error?.code).toBe("DEVICE_NOT_ENROLLABLE"); - expect(await rows(env)).toEqual([]); + expect(http.body.error).toMatchObject({ + code: "DEVICE_NOT_ENROLLABLE", + id: "NOT-PLUGGED-IN", + reason: "not-connected", + }); + expect(await rows(daemon.env)).toEqual([]); }); it("naming a Simlock simulator's driverDeviceId, or an emulator's driverDeviceId and, separately, its address, answers virtual-device and the driver log has no inspectPhysical call", async () => { @@ -546,7 +555,7 @@ describe("device add over HTTP", () => { const accepted = await remove(operator); expect(accepted.status).toBe(200); - expect(await accepted.json()).toEqual({ driverDeviceId: UDID, id: UDID }); + expect(await accepted.json()).toEqual({ driverDeviceId: UDID, id: enrolled.id }); expect((await rows(env)).find((row) => row.id === enrolled.id)?.state).toBe("deleted"); }); }); @@ -622,5 +631,11 @@ describe("android enrolment", () => { expect(result.stdout).toBe( "Enrolled Pixel 9 (Android 15) as R58M123ABC, 1 user app recorded.\n", ); + const state = JSON.parse(await readFile(join(env.home, "state.json"), "utf8")) as { + physicalDevices: { driverDeviceId: string; enrolledApps: string[] }[]; + }; + expect(state.physicalDevices.map((record) => record.enrolledApps)).toEqual([ + ["com.example.mail"], + ]); }); }); diff --git a/src/core/doctor.test.ts b/src/core/doctor.test.ts index 84bd80809..b8a7eb797 100644 --- a/src/core/doctor.test.ts +++ b/src/core/doctor.test.ts @@ -1315,7 +1315,7 @@ describe("Doctor", () => { }); }); - it("reports no live-drift finding for a quarantined simulator while its retry holds its claim, and registry-device-missing once the fake no longer lists it", async () => { + it("reports no live-drift finding for a quarantined simulator while its retry runs, and registry-device-missing once the fake no longer lists it", async () => { const clock = new FakeClock(10_000); const eventBus = new EventBus(clock); const registry = await Registry.load({ diff --git a/src/core/driver.test.ts b/src/core/driver.test.ts index f81c10b2c..d225ea0a2 100644 --- a/src/core/driver.test.ts +++ b/src/core/driver.test.ts @@ -6,6 +6,7 @@ import { DiskSpaceGuard, type Driver, type DriverDevice, + type DriverRefusalReason, InsufficientDiskSpaceError, type PhysicalInspection, sameReceipt, @@ -214,28 +215,37 @@ describe("the virtual-only driver and lifecycle calls (ADR 0022 §4, §5)", () = }); describe("PhysicalInspection (ADR 0022 §5)", () => { - /** Never run: a refusal naming the core's own reason must not compile. */ - function refusalReasons(): void { - const refused: PhysicalInspection = { - enrollable: false, - message: "not plugged in", - reason: "not-connected", - }; - const refusedAsVirtual: PhysicalInspection = { - enrollable: false, - message: "a simulator", - reason: "virtual-device", - }; + function otherPlatformDoesNotCompile(): void { const otherPlatform: PhysicalInspection = { enrollable: false, message: "enrolled as iOS", // @ts-expect-error only the core can know a device is enrolled on the other platform reason: "enrolled-on-other-platform", }; - void [refused, refusedAsVirtual, otherPlatform]; + void otherPlatform; } it("lets a driver refuse with every reason but enrolled-on-other-platform", () => { - expect(refusalReasons).toBeTypeOf("function"); + const reasons: Record = { + "developer-mode-off": true, + locked: true, + "network-only": true, + "not-connected": true, + "os-too-old": true, + unreadable: true, + "unsupported-kind": true, + untrusted: true, + "virtual-device": true, + }; + + const refusals = (Object.keys(reasons) as DriverRefusalReason[]).map( + (reason): PhysicalInspection => ({ enrollable: false, message: `refused ${reason}`, reason }), + ); + + expect(refusals).toHaveLength(9); + expect(refusals.map((refusal) => !refusal.enrollable && refusal.reason)).not.toContain( + "enrolled-on-other-platform", + ); + expect(otherPlatformDoesNotCompile).toBeTypeOf("function"); }); }); diff --git a/src/core/physical-enrollment.test.ts b/src/core/physical-enrollment.test.ts index 8370dedd1..7362b7956 100644 --- a/src/core/physical-enrollment.test.ts +++ b/src/core/physical-enrollment.test.ts @@ -302,7 +302,7 @@ describe("PhysicalEnrollment", () => { }, ); - it("answers INTERNAL and writes nothing when the first inspection throws", async () => { + it("rejects with a PhysicalInspectionError, not a refusal, and writes nothing when the first inspection throws", async () => { const harness = await build(); harness.driver.failOn("inspectPhysical", 1, new Error("usbmuxd went away")); @@ -314,7 +314,7 @@ describe("PhysicalEnrollment", () => { expect(enrolledEvents(harness)).toEqual([]); }); - it("answers INTERNAL and writes nothing when the second inspection names another canonical ID, and a remove right after it succeeds", async () => { + it("rejects with a PhysicalInspectionError and writes nothing when the second inspection names another canonical ID, and a remove right after it succeeds", async () => { const harness = await build(); await harness.core.enrollment.add("ios", UDID); const before = JSON.stringify(live(harness)); @@ -422,7 +422,7 @@ describe("PhysicalEnrollment", () => { expect(live(harness)).toHaveLength(1); }); - it("words a busy device as leased, reclaiming or being enrolled or removed", async () => { + it("words a busy device as leased, reclaiming or busy with another operation", async () => { const harness = await build(); const { device } = await harness.core.enrollment.add("ios", UDID); const claim = harness.core.claims.tryClaim(device.id, "reclaim"); @@ -432,7 +432,7 @@ describe("PhysicalEnrollment", () => { claim?.release(); const enrolling = harness.core.claims.tryClaim(device.id, "enrollment"); await expect(harness.core.enrollment.remove(UDID)).rejects.toThrow( - `${UDID} is being enrolled or removed: try again in a moment`, + `${UDID} is busy with another operation: try again in a moment`, ); enrolling?.release(); await harness.registry.createLease({ diff --git a/src/core/physical-enrollment.ts b/src/core/physical-enrollment.ts index 593c9fa01..ae60d9dea 100644 --- a/src/core/physical-enrollment.ts +++ b/src/core/physical-enrollment.ts @@ -9,11 +9,9 @@ export type DeviceNotEnrollableReason = DriverRefusalReason | "enrolled-on-other export class DeviceNotEnrollableError extends Error { constructor( readonly platform: Platform, - /** The ID as the operator typed it. */ readonly id: string, readonly reason: DeviceNotEnrollableReason, message: string, - /** Set when the core knows the device's canonical ID. */ readonly canonicalId?: string, ) { super(message); @@ -31,7 +29,7 @@ export class DeviceBusyError extends Error { ? `${id} is leased: release it first` : reason === "reclaiming" ? `${id} is being reclaimed: try again when it finishes` - : `${id} is being enrolled or removed: try again in a moment`, + : `${id} is busy with another operation: try again in a moment`, ); this.name = "DeviceBusyError"; } diff --git a/src/core/quarantine-coordinator.test.ts b/src/core/quarantine-coordinator.test.ts index 8889dfb6d..5d5b7ea57 100644 --- a/src/core/quarantine-coordinator.test.ts +++ b/src/core/quarantine-coordinator.test.ts @@ -802,30 +802,35 @@ describe("QuarantineCoordinator retry claim (ADR 0022 §2)", () => { }, ); - it.each([ - ["recovers the device", {}, 1], - [ - "deletes a spent fresh device", - { target: { lastLeaseEndedAt: 900, leaseIdentity: "fresh" } as const }, - 1, - ], - ] as const)( - "has freed the claim before it emits and before it wakes availability when a retry %s", - async (_case, options, wakes) => { - const harness = await stalled(options); - const claimedAt: boolean[] = []; - const sees = () => claimedAt.push(harness.claims.isClaimed(harness.target.id)); - harness.bus.subscribe("device.quarantine-recovered", sees); - harness.notifyAvailability.mockImplementation(sees); + it("has freed the claim before it emits and before it wakes availability when a retry recovers the device", async () => { + const harness = await stalled({}); + const claimedAt: boolean[] = []; + const sees = () => claimedAt.push(harness.claims.isClaimed(harness.target.id)); + harness.bus.subscribe("device.quarantine-recovered", sees); + harness.notifyAvailability.mockImplementation(sees); - harness.clock.advance(retryConfig.retryBackoffMs); - await flush(); + harness.clock.advance(retryConfig.retryBackoffMs); + await flush(); - expect(harness.notifyAvailability).toHaveBeenCalledTimes(wakes); - expect(claimedAt.length).toBeGreaterThan(0); - expect(claimedAt.every((claimed) => !claimed)).toBe(true); - }, - ); + expect(harness.notifyAvailability).toHaveBeenCalledTimes(1); + expect(claimedAt).toEqual([false, false]); + }); + + it("has freed the claim before it wakes availability when a retry deletes a spent fresh device", async () => { + const harness = await stalled({ target: { lastLeaseEndedAt: 900, leaseIdentity: "fresh" } }); + const claimedAt: boolean[] = []; + harness.notifyAvailability.mockImplementation(() => + claimedAt.push(harness.claims.isClaimed(harness.target.id)), + ); + + harness.clock.advance(retryConfig.retryBackoffMs); + await flush(); + + expect(harness.registry.snapshot.virtualDevices.map((device) => device.state)).toEqual([ + "deleted", + ]); + expect(claimedAt).toEqual([false]); + }); it("has freed the claim before it emits and before it wakes availability when a retry gives up", async () => { const clock = new FakeClock(1_000); diff --git a/src/core/registry.test.ts b/src/core/registry.test.ts index 50b0cc909..c9e1e63d9 100644 --- a/src/core/registry.test.ts +++ b/src/core/registry.test.ts @@ -2883,6 +2883,9 @@ describe("Registry: device.deleted carries physical, and enrollment writes (ADR await expect(registry.reenrollPhysical(device.id, input)).rejects.toThrow(RegistryEventError); await expect(registry.unenrollPhysical(device.id)).rejects.toThrow(RegistryEventError); await expect(registry.unenrollPhysical(virtual.id)).rejects.toThrow(RegistryEventError); + await expect(registry.reenrollPhysical(virtual.id, input)).rejects.toThrow( + `Cannot re-enrol device: ${virtual.id}`, + ); await expect(registry.reenrollPhysical(device.id, input)).rejects.toThrow( `Cannot re-enrol device: ${device.id}`, ); diff --git a/src/core/registry.ts b/src/core/registry.ts index 840b61f6e..8b47942fe 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -12,6 +12,7 @@ import { type DeviceRecord, type DeviceState, type DeviceTransitionUpdate, + IllegalTransition, isSettled, type LeaseGrant, type LeaseIdentity, @@ -123,7 +124,6 @@ export interface CreateLeaseInput { readonly deviceId: string; readonly requesterId: string; readonly ownerId: string; - /** The width this lease is granted with; stored on the record, see `LeaseRecord.ttlMs`. */ readonly ttlMs: number; readonly ttlDeadline: number; /** @@ -299,7 +299,6 @@ export class Registry implements LeaseRequestStore { deviceId: string, to: DeviceState, event?: RegistryDeviceEvent, - /** Fields a driver call resolved alongside this transition -- currently a fresh `makeReady` address. */ update?: DeviceTransitionUpdate, ): Promise { const { device, index } = this.#requireDeviceRecord(deviceId); @@ -321,12 +320,6 @@ export class Registry implements LeaseRequestStore { return cloneDevice(updated); } - /** - * Commits `reclaiming -> shutdown` for a device whose driver shutdown ran without a purge, so - * no `device.reclaimed` fact fits: an unleased reclaim interrupted by daemon shutdown and found - * at startup, or a spent fresh device's lease-end shutdown before its delete. The caller emits - * whatever fact its own path owns. - */ async completeReclaimWithoutPurge(deviceId: string): Promise { const { device, index } = this.#requireDeviceRecord(deviceId); if (device.state !== "reclaiming") { @@ -448,11 +441,6 @@ export class Registry implements LeaseRequestStore { return cloneDevice(updated); } - /** - * Commits `quarantined -> deleted` once the driver `destroy` has already run: a spent fresh - * device whose retried delete succeeded (initiator `lease-end`), or any device quarantine gave - * up on (initiator `quarantine-coordinator`). - */ // fallow-ignore-next-line unused-class-member -- called through QuarantineCoordinator's registry port. async deleteQuarantined(deviceId: string, initiator: string): Promise { const { device, index } = this.#requireDeviceRecord(deviceId); @@ -476,7 +464,6 @@ export class Registry implements LeaseRequestStore { return cloneDevice(updated as DeviceRecord); } - /** Flags a device whose observed boot state disagrees with the committed registry state. */ async markForeignStateDetected(deviceId: string, at: number): Promise { const { device, index } = this.#requireDeviceRecord(deviceId); // Doctor re-reports persistent drift on every reaper tick; keep the first-detected @@ -491,7 +478,6 @@ export class Registry implements LeaseRequestStore { return cloneDevice(updated); } - /** Flags a device whose provenance marks no longer prove Simlock owns it. */ async markForeignProvenanceDetected(deviceId: string, at: number): Promise { const { device, index } = this.#requireDeviceRecord(deviceId); if (device.foreignProvenanceDetectedAt !== undefined) { @@ -552,7 +538,6 @@ export class Registry implements LeaseRequestStore { return cloneDevice(updated); } - /** Records externally verified disappearance; no driver verb is invoked. */ async markDeviceMissing(deviceId: string, initiator: string): Promise { const { device } = this.#requireDeviceRecord(deviceId); if (this.#leases.some((lease) => lease.deviceId === deviceId)) { @@ -666,8 +651,9 @@ export class Registry implements LeaseRequestStore { deferredReclaimLeaseId: _deferred, ...kept } = device; + const settled = device.state === "quarantined" ? transition(kept, "ready") : kept; const updated: PhysicalDevice = { - ...kept, + ...settled, enrolledApps: [...input.apps], readyAt: this.options.clock.now(), spec: { @@ -677,7 +663,6 @@ export class Registry implements LeaseRequestStore { physical: true, platform: device.spec.platform, }, - state: "ready", }; const devices = [...this.#devices]; devices[index] = updated; @@ -688,16 +673,22 @@ export class Registry implements LeaseRequestStore { async unenrollPhysical(deviceId: string): Promise { const { device, index } = this.#requireDeviceRecord(deviceId); - if (!device.physical || (device.state !== "ready" && device.state !== "quarantined")) { - throw new RegistryEventError(`Cannot unenrol device: ${deviceId}`); - } + if (!device.physical) throw new RegistryEventError(`Cannot unenrol device: ${deviceId}`); const { quarantineAttempts: _quarantineAttempts, quarantineNextRetryAt: _quarantineNextRetryAt, quarantinedAt: _quarantinedAt, ...kept } = device; - const updated = transition(kept, "deleted"); + let updated: PhysicalDevice; + try { + updated = transition(kept, "deleted"); + } catch (error) { + if (error instanceof IllegalTransition) { + throw new RegistryEventError(`Cannot unenrol device: ${deviceId}`); + } + throw error; + } const devices = [...this.#devices]; devices[index] = updated; await this.#commit(devices, this.#leases); diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 10dbfa436..9c486774e 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -158,37 +158,16 @@ function withHttp(config: Config, http: Config["http"] | undefined): Config { async function buildDispatcher( overrides: { readonly downloadsPolicy?: Config["downloads"]["policy"]; - /** Extra fake driver options, such as `knownModels` for a catalog that lists a model. */ readonly driverOptions?: Partial; readonly awaitReady?: () => Promise; - /** Overrides the `catalog` dependency -- used only to force `#parseOutput`'s failure path - * (see "Dispatcher: #parseOutput") with a fake that returns a payload violating the - * contract's output schema, something no real `CatalogReader` implementation would do. */ readonly catalog?: CatalogReader; - /** ADR §3: `nuke.run` is admin-only and destructive (`safety.md` rules 1/5). `false` by - * default -- matching `DispatcherOptions.nuke`'s own undefined default, so most tests don't - * pay for wiring one -- since a real `Nuke` needs this function's own `engine`/`registry`, - * it is built here (rather than passed in) when a test opts in. */ readonly includeNuke?: boolean; - /** Wires `device.add` and `device.remove` to the engine's real `PhysicalEnrollment`; off by - * default, like the option it feeds. */ readonly includeEnrollment?: boolean; - /** Gives the fake driver a `simlock ` wrapper so `driver.passthrough` has something - * to route to; off by default so no other test's catalog output changes shape. */ readonly passthroughTool?: string; - /** Narrows the lease TTL knobs (ADR 0004), for the cap and default-width rules. */ readonly lease?: Partial; - /** Wires `device.exec`'s runner (ADR 0005 §19a); absent by default, like the option it - * feeds, so no other test pays for a scripted process. */ readonly processRunner?: ProcessRunner; - /** Narrows `exec.timeoutMs` so the timeout path can be driven with a short clock advance - * rather than ten minutes of one. */ readonly exec?: Partial; - /** Collects the `PassthroughContext` each resolution was given, so a test can assert what - * the driver was told about its caller (ADR 0005 §19c's "no terminal"). */ readonly passthroughContextSink?: unknown[]; - /** Shares one clock with the test, for a flow that has to schedule against the dispatcher's - * own timers. */ readonly clock?: FakeClock; /** Replaces the fake driver's own `passthrough` (which unconditionally prepends * `--set /root`) with a caller-supplied resolver -- for the one suite that needs a @@ -3346,7 +3325,7 @@ describe("Dispatcher: device.add and device.remove", () => { platform: "ios", reenrolled: false, }); - expect(removed).toEqual({ driverDeviceId: UDID, id: UDID }); + expect(removed).toEqual({ driverDeviceId: UDID, id: registry.snapshot.devices[0]?.id }); expect(registry.snapshot.devices[0]?.state).toBe("deleted"); }); @@ -3451,7 +3430,6 @@ describe("Dispatcher: component.remove", () => { return driver.calls.filter((call) => call.operation === operation).length; } - /** A dispatcher over a real engine and installer, with iOS 27.0 installed by Simlock. */ async function withSimlockInstall() { const built = await buildDispatcher({ driverOptions: { componentSizes: { "27.0": 7_000_000_000 }, knownModels: ["iPhone 17 Pro"] }, diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index 2d8045137..86ffb629d 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -112,9 +112,7 @@ export interface DispatcherOptions { readonly components: Pick; readonly config: Config; readonly doctor?: Doctor; - /** Answers `events.replay`: the ring, or the event file for a `sinceTs`. */ readonly eventHistory: Pick; - /** Answers whether a device's pool is the one a request naming no mode draws from. */ readonly deviceModes: DeviceModeReader; readonly leases: LeaseCommands; readonly logger?: Logger; @@ -291,20 +289,14 @@ export class Dispatcher { "worker.install-component", "worker.install-component installs on a gateway's workers, and this daemon is not a gateway; install on this host without naming workers", ), - // "daemon.stop" deliberately absent -- see the class comment; `DaemonServer` never calls - // `dispatch()` for a frame type this map has no entry for. }; } - /** Ends this dispatcher's bus subscriptions. `DaemonServer` calls it when the daemon stops. */ dispose(): void { for (const unsubscribe of this.#unsubscribe.splice(0)) unsubscribe(); this.#viewCatalog = undefined; } - /** ADR 0003 §2's pipeline, run by the shared `runDispatch` (see `./dispatch.js`) over this - * dispatcher's own handlers, lease lookups, and startup gate. The ordering and the checks - * are the contract's; only the three inputs below are this implementation's. */ dispatch( operation: Op, rawInput: unknown, @@ -841,7 +833,7 @@ export class Dispatcher { #deviceRemove: Handler<"device.remove"> = async (input) => { try { const device = await this.#requireEnrollment().remove(input.id); - return { driverDeviceId: device.driverDeviceId, id: device.driverDeviceId }; + return { driverDeviceId: device.driverDeviceId, id: device.id }; } catch (error: unknown) { throw describeEnrollmentError(error); } @@ -855,9 +847,6 @@ export class Dispatcher { } /** - * ADR §11: the daemon is the only owner of `tokens.json` -- `TokenStore.create` never - * persists the plaintext `secret`, only its hash, exactly as it did when the CLI called it - * directly. */ #tokenCreate: Handler<"token.create"> = async (input) => { const { record, secret } = await this.#requireTokens().create(input.role, input.label); @@ -962,7 +951,6 @@ function describeEnrollmentError(error: unknown): unknown { return error; } -/** ADR 0012 §2: an operation that acts on a gateway's workers, asked of a worker. */ function unsupportedOnWorker( operation: OperationName, message = `${operation} acts on a gateway's workers, and this daemon is not a gateway`, diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index f571ce56e..64b9883e6 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -202,12 +202,6 @@ describe("DaemonServer", () => { }, ); - // Every protocol bump so far shipped without a back-compat shim, so rejecting an older - // client outright is a deliberate product decision, not just arithmetic on the current - // constant. Protocol 3 (ADR 0003) turned the rejection into a range check; 4 (ADR 0004) - // removed `lease.heartbeat` and `mode` behind that same no-shim rule; 5 (ADR 0005) adds - // `device.exec` and a `mode` field on `status.get` the same way. A v1 client has no overlap - // with `PROTOCOL_VERSION_RANGE` and is still rejected outright. it("rejects a protocol v1 client outright rather than serving it a wire it cannot speak", async () => { const harness = await createHarness(); const legacy = await createClient(harness.socketPath); @@ -638,7 +632,6 @@ describe("DaemonServer", () => { payload: { component: "27.0", outcome: "installed", platform: "ios", version: "27.0" }, }); expect(client.frames().filter((frame) => frame.push === "component-progress")).toEqual([ - // The install's start, with no fraction: the schema accepts it as a push. { payload: { progress: { stage: "downloading" }, requestId: "install-frame" }, push: "component-progress", @@ -647,7 +640,6 @@ describe("DaemonServer", () => { payload: { progress: { fraction: 0.5, stage: "downloading" }, requestId: "install-frame" }, push: "component-progress", }, - // The install succeeded: its final report, before the reply. { payload: { progress: { fraction: 1, stage: "downloading" }, requestId: "install-frame" }, push: "component-progress", @@ -677,15 +669,11 @@ describe("DaemonServer", () => { }); expect(removed).toMatchObject({ ok: true, - payload: { driverDeviceId: "UDID-1", id: "UDID-1" }, + payload: { driverDeviceId: "UDID-1", id: "dev_1" }, }); }); it("routes two commands on one connection by frame id, and pushes to no other connection", async () => { - // ADR 0005 §19a keys `output` on the request's frame id for the same reason `progress` is - // keyed on it: one connection may have several calls in flight. And a push is a *reply* to - // one call, not a broadcast -- a second connection, even one owned by the same principal, - // has no business seeing another's command output. const clock = new FakeClock(1_000); const driver = passthroughDriver(clock); const harness = await createHarness({ @@ -2921,25 +2909,12 @@ async function createHarness( readonly beginStop?: () => void; readonly stateFilesystem?: MemoryFilesystem; readonly stopAuxiliary?: () => Promise; - /** ADR 0003 §5's per-start admin secret (`AdminSecretManager`). Undefined by default, same - * as `DaemonServer`'s own default -- most of this suite doesn't exercise the credential - * handshake at all. A test that needs to observe `persist()`/`remove()` calls (or their - * absence -- see the "loses the start race" test) injects a spy here. */ readonly adminSecret?: AdminSecretManager; - /** ADR 0003 §5's seam (see `session.ts`): defaults every session in this harness to - * "admin" -- this suite predates roles and exercises every operation freely, the same - * access a pre-ADR-0003 connection always had. Tests that specifically exercise role - * enforcement (ADR §3) override this to get an "agent" (or mixed) session instead. */ readonly resolveRole?: SessionRoleResolver; - /** Overrides the `downloads` config block for tests that exercise the download policy. */ readonly downloads?: Partial; - /** Wires `device.exec`'s runner (ADR 0005 §19a); absent by default like the option itself. */ readonly processRunner?: ProcessRunner; - /** Replaces the real socket host, for a test that has to control a frame's write. */ readonly host?: ConnectionHost; - /** Overrides the protocol range this daemon advertises, to stand in for an older worker. */ readonly protocolVersion?: ProtocolRange; - /** Wires `device.add` and `device.remove` to the engine's enrollment; absent by default. */ readonly includeEnrollment?: boolean; } = {}, ) { diff --git a/src/leasing/create-leasing.test.ts b/src/leasing/create-leasing.test.ts index abe1023c6..a9ffa8ac4 100644 --- a/src/leasing/create-leasing.test.ts +++ b/src/leasing/create-leasing.test.ts @@ -4139,13 +4139,12 @@ describe("createLeasing after a quarantine retry (ADR 0022 §2)", () => { harness.clock.advance(config().warmPool.quarantine.retryBackoffMs); await flush(); - const grant = await waiting; - expect(grant.device.id).toBe(first.device.id); + expect(await settledOrPending(waiting)).toMatchObject({ device: { id: first.device.id } }); expect(driver.calls.filter((call) => call.operation === "makeReady")).toHaveLength(bootsBefore); expect(driver.calls.filter((call) => call.operation === "provision")).toHaveLength(1); }); - it("reports a warm target of one for the quarantined simulator's spec as ready 1 in the pass the retry's device.quarantine-recovered starts", async () => { + it("reports a warm target of one for the quarantined simulator's spec as ready 1 once the retry has emitted device.quarantine-recovered", async () => { const clock = new FakeClock(1_000); const driver = new FakeDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }); driver.failOn("reclaim", 1, new DriverCrashError("purge exploded")); @@ -4166,6 +4165,12 @@ describe("createLeasing after a quarantine retry (ADR 0022 §2)", () => { await flush(); await harness.engine.settle(); + expect( + harness.bus + .replay() + .filter((event) => event.event === "device.quarantine-recovered") + .map((event) => (event.payload as { deviceId: string }).deviceId), + ).toEqual([first.device.id]); expect(harness.registry.snapshot.devices[0]?.state).toBe("ready"); expect(harness.engine.core.warmPoolReader.figures().targets).toMatchObject([ { count: 1, ready: 1 }, From 53a3d5446950a0b2c2d5582b9e9533efbf36e98b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 11:04:04 +0200 Subject: [PATCH 15/22] chore: delete comments that say what, point elsewhere or tell history (#437) --- e2e/console/workers.spec.ts | 1 - e2e/fake-driver/types.ts | 21 -------- src/bus/index.ts | 2 - src/cli/index.test.ts | 9 ---- src/cli/index.ts | 33 ------------ src/contract/errors.ts | 82 ----------------------------- src/contract/operations.test.ts | 3 -- src/contract/operations.ts | 32 +---------- src/contract/protocol.ts | 40 +------------- src/contract/schemas.ts | 78 --------------------------- src/core/create-core.ts | 14 ----- src/core/device-operation-claims.ts | 16 ------ src/core/driver.ts | 41 --------------- src/core/fake-driver.ts | 12 ----- src/core/quarantine-coordinator.ts | 20 ------- src/daemon-protocol/index.test.ts | 10 ---- src/daemon/server.ts | 35 +----------- src/gateway/dispatcher.test.ts | 4 -- src/gateway/dispatcher.ts | 5 +- src/http/app.ts | 10 ---- src/simlock-client/client.test.ts | 5 -- src/simlock-client/client.ts | 28 +--------- src/simlock-client/types.ts | 4 -- ui/src/views/workers.test.tsx | 3 -- 24 files changed, 6 insertions(+), 502 deletions(-) diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index cbb56efb8..134197053 100644 --- a/e2e/console/workers.spec.ts +++ b/e2e/console/workers.spec.ts @@ -337,7 +337,6 @@ test.describe("the workers views", () => { await expect(cells.nth(2), device.id).toHaveText("—"); continue; } - // A leased device: in that state since its lease was granted, counting on with no reload. await expect(cells.nth(2), device.id).toHaveText(/^\d+ s$/); const shown = Number.parseInt((await cells.nth(2).textContent()) ?? "", 10); // Within 3 seconds: the Date header counts whole seconds, the page shows whole seconds, diff --git a/e2e/fake-driver/types.ts b/e2e/fake-driver/types.ts index f1c8eb126..e7db2fc38 100644 --- a/e2e/fake-driver/types.ts +++ b/e2e/fake-driver/types.ts @@ -22,13 +22,11 @@ export type FakeDriverOperation = | "listCatalog" | "listComponents" | "removeComponent" - /** The root re-proof `doctor --purge-orphans` takes before its first destroy. */ | "revalidateRoot" | "inspectPhysical"; export type FakeDriverEstimateOperation = "provision" | "boot" | "reclaim"; -/** A forced failure for one operation. `type: "generic"` throws a plain Error. */ export type FakeDriverErrorSpec = | { readonly type: "RuntimeMissingError"; readonly osVersion?: string } | { readonly type: "UnknownModelError"; readonly model?: string } @@ -74,7 +72,6 @@ export interface ScriptedProcessEntry { readonly deviceId: string; } -/** Overrides `listManaged()` so doctor drift (unknown/vanished devices) can be staged. */ export interface ScriptedManagedReality { readonly devices?: readonly ScriptedObservedDevice[]; readonly processes?: readonly ScriptedProcessEntry[]; @@ -122,18 +119,9 @@ export interface FakeDriverPlatformScript { readonly version: string; readonly build?: string; }[]; - /** - * The percentages each `installComponent` reports through `onProgress`, in order. An install - * makes its version available for the rest of the daemon's life, on top of - * `availableOsVersions`; `latencyMs.installComponent` makes it wait and - * `failures.installComponent` makes it fail. - */ readonly installProgress?: readonly number[]; - /** What `listComponents` reports as each version's size in bytes; absent for one left out. */ readonly componentSizes?: Readonly>; - /** What `listComponents` reports as each version's foreign devices; 0 for one left out. */ readonly foreignDevices?: Readonly>; - /** What every `removeComponent` reports as left on disk; nothing unless set. */ readonly removalResidue?: string; readonly latencyMs?: Partial>; readonly estimateMs?: Partial>; @@ -142,11 +130,6 @@ export interface FakeDriverPlatformScript { readonly failures?: Partial>; readonly managedReality?: ScriptedManagedReality; readonly physicalDevices?: readonly ScriptedPhysicalDevice[]; - /** - * Environment the grant for this platform's devices should carry, standing in for the - * device-set path / adb port a real driver contributes. Read on every operation like - * everything else here, so a test can set it before the lease it wants to assert on. - */ readonly leaseEnvironment?: Readonly>; /** * Overrides the `address` a `makeReady` boot returns (the script is re-read fresh on every @@ -155,10 +138,6 @@ export interface FakeDriverPlatformScript { * Defaults to `-address`. */ readonly address?: string; - /** - * What this platform's `prerequisiteChecks` entry reports missing, read fresh on every - * `doctor` run. None by default. - */ readonly missingPrerequisites?: readonly { readonly prerequisite: string; readonly message: string; diff --git a/src/bus/index.ts b/src/bus/index.ts index 300058aed..c6a86695e 100644 --- a/src/bus/index.ts +++ b/src/bus/index.ts @@ -219,9 +219,7 @@ export interface EventMap { }; "component.install-started": { readonly platform: string; - /** The component string the install was asked for: a version, or a driver's word for newest. */ readonly componentId: string; - /** The requester whose call started this install, when one is known. */ readonly requesterId?: string; }; "component.installed": { diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 5c7ec17a3..1ec941cb8 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1578,7 +1578,6 @@ describe("CLI: device add and device remove (ADR 0022 §2)", () => { }); describe("CLI: component remove (ADR 0010 §8)", () => { - /** A client that records every removal it is asked for and answers it. */ function removingClient(asked: unknown[]) { return fakeClient({ removeComponent: async (input) => { @@ -5371,14 +5370,6 @@ interface OutputCapture { environmentWith(overrides?: Partial): CliEnvironment; } -/** - * `ports` is `undefined` for the fully-mocked default every other suite uses (a bare object - * literal, `connectAdmin` etc. never call their argument unless a test's override does). - * Passing `ports` (built with `realCliEnvironmentPorts`, or by hand for the cold-start/B1 - * suites) routes `environmentWith` through the real `buildCliEnvironment` instead -- the only - * way to exercise the production credential-resolution ordering (B2) and the real config - * validator (B9) rather than re-testing a test double. - */ function outputCapture(ports?: CliEnvironmentPorts): OutputCapture { const capture: OutputCapture = { stdout: "", diff --git a/src/cli/index.ts b/src/cli/index.ts index 1cd9669ae..861a8b4fa 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -87,21 +87,8 @@ Pass --token anywhere on the command line to connect as admin explicitly; see https://github.com/callstackincubator/simlock for the default resolution order.`; -/** - * A `lease` that stayed alive ends with the lease already gone: the daemon ended it without - * the holder asking (TTL expiry, an operator `release`, or a leased device that could not be - * recovered). Distinct from 0, which means the holder ended its own lease, and from 1 on a - * dead connection, which means nothing ended the lease at all -- it is still granted and still - * counting down (ADR 0004 §3). - */ const LEASE_LOST_EXIT_CODE = 14; -/** - * `component install --worker`/`--all-workers` on a gateway: at least one worker did not end - * `installed` or `already-installed` -- it refused, failed, was skipped, or its result is - * unknown. Like 14, an outcome of the command rather than a daemon error code: the call itself - * succeeded, and the per-worker report on stdout says which worker ended how. - */ const WORKER_INSTALL_INCOMPLETE_EXIT_CODE = 15; /** @@ -635,16 +622,6 @@ export async function runCli( return await runDevice(rest.slice(1), environment, token); case "simctl": case "adb": - // Named here rather than discovered from the drivers on purpose: these are - // user-facing command names published in `docs/CLI.md`, and the CLI never builds a - // simctl or adb argument -- it forwards the name and spawns whatever the daemon - // hands back, so which flags scope the tool and which verbs it refuses still live - // entirely in the driver (architecture rule 2 is about knowledge, not about names). - // Every argument after the tool name is the tool's, verbatim -- including `--help`, - // which belongs to `simctl`/`adb` and not to Simlock. `simlock --help` lists these. - // Narrowed here rather than passed as a bare string: both operations take an open - // `tool` (which wrappers exist is the drivers' answer, not the contract's), but this - // CLI publishes exactly two wrapper commands, and this switch already knows which. return await runPassthrough( rest[0] === "adb" ? "adb" : "simctl", rest.slice(1), @@ -2057,16 +2034,6 @@ const COMPONENT_USAGE = " simlock component list [--platform ]\n" + " simlock component remove [--dry-run] [--yes]\n"; -/** - * ADR 0010 §6: installs one component through the daemon's `component.install`. Argument - * parsing and rendering only (architecture rule 8): whether the install may run, joins another - * or is already done is the daemon's answer. The result is one JSON line on stdout; progress - * is JSON lines on stderr, the same split every other command makes. - * - * With `--worker` or `--all-workers` it calls a gateway's `worker.install-component` instead - * (ADR 0010 §7): each progress line names its worker, the result lists one entry per worker, and - * the command exits 15 unless every worker ended `installed` or `already-installed`. - */ async function runComponent( argv: readonly string[], environment: CliEnvironment, diff --git a/src/contract/errors.ts b/src/contract/errors.ts index 81d36cd12..b38c711a8 100644 --- a/src/contract/errors.ts +++ b/src/contract/errors.ts @@ -1,14 +1,3 @@ -/** - * One error class, a closed set of codes, typed details (ADR 0003 §7). - * - * `kind` is `transport` (unavailable, connection lost, startup timeout), `protocol` (version, - * handshake, bad request, forbidden, authentication), or `domain` (capacity, unknown lease, - * runtime missing, ...). `cliExitCode`/`httpStatus` are columns of the same table, per the - * ADR's explicit instruction -- not a second mapping a CLI or HTTP frontend maintains on its - * own. Nothing in this repo consumes those two columns yet; they land in later PRs (the CLI - * still has its own `DAEMON_ERROR_EXIT_CODES` map and HTTP its own `mapError`, both untouched - * by this PR -- wiring them to this table is follow-up work called out in the PR description). - */ import type { DeviceClass, Platform } from "./schemas.js"; const DEVICE_NOT_ENROLLABLE_REASONS = [ @@ -28,32 +17,23 @@ export type DeviceNotEnrollableReason = (typeof DEVICE_NOT_ENROLLABLE_REASONS)[n export type ErrorKind = "transport" | "protocol" | "domain"; -/** Per-code `details` shapes. `Record` means "no details, an empty object". */ export interface ErrorDetailsMap { - // -- protocol -- BAD_FRAME: Record; HANDSHAKE_REQUIRED: Record; BAD_REQUEST: Record; UNKNOWN_REQUEST: Record; - /** ADR §6. Reported by the daemon on a failed `hello` range negotiation, and constructed by - * a client mapping a legacy protocol-2 daemon's exact-match mismatch onto this shape. */ PROTOCOL_VERSION_UNSUPPORTED: { readonly client: { readonly min: number; readonly max: number }; readonly daemon: { readonly min: number; readonly max: number }; readonly daemonVersion: string; }; - /** ADR §5. Not yet thrown anywhere in this PR -- the credential handshake is PR 2 -- but - * part of the closed set from the start, per the ADR's explicit instruction. */ ADMIN_AUTHENTICATION_FAILED: Record; - /** ADR §2 (dispatcher role check). Not yet thrown -- PR 2 -- declared for the same reason. */ FORBIDDEN: Record; - // -- transport -- DAEMON_STOPPING: Record; DAEMON_STARTUP_FAILED: Record; DAEMON_CONNECTION_LOST: Record; - // -- domain -- NO_CAPACITY: Record; QUEUE_TIMEOUT: { readonly requestId: string }; REQUESTER_ALREADY_LEASED: { readonly requesterId: string; readonly existingLeaseId?: string }; @@ -96,25 +76,10 @@ export interface ErrorDetailsMap { * no operation overrides that policy, so the install never starts. */ DOWNLOADS_DISABLED: { readonly policy: "never" }; - /** - * ADR 0010 §5/§8: a `component.remove` for a component Simlock did not install -- no record of - * installing it, or what is installed now is not what the record names. Nothing was removed. - */ COMPONENT_NOT_OWNED: Record; - /** - * ADR 0010 §8: a `component.remove` for a component a device uses: `devices` of Simlock's own, - * in any state but deleted, and `foreignDevices` outside Simlock. Nothing was removed. - */ COMPONENT_IN_USE: { readonly devices: number; readonly foreignDevices: number }; - /** ADR 0010 §8: a `component.remove` while an install or removal runs or waits on the platform. */ COMPONENT_BUSY: Record; UNKNOWN_LEASE: { readonly leaseId: string }; - /** - * ADR 0022 §2: `device.add` of a device that cannot be enrolled. `id` is the ID as typed; - * `canonicalId` is set when the core knows it (`enrolled-on-other-platform`, and a refusal of - * the second inspection). The driver picks every reason but `enrolled-on-other-platform`, and - * `virtual-device` for Simlock's own simulators and emulators is the core's. - */ DEVICE_NOT_ENROLLABLE: { readonly platform: Platform; readonly id: string; @@ -123,40 +88,13 @@ export interface ErrorDetailsMap { }; DEVICE_BUSY: { readonly id: string; readonly reason: "leased" | "reclaiming" | "busy" }; UNKNOWN_DEVICE: { readonly id: string }; - /** A `simlock ` verb the owning driver will not proxy (ADR 0001, decision 7). Carries - * the tool so a caller can say which wrapper refused without re-parsing the message. */ PASSTHROUGH_REFUSED: { readonly tool: string }; - /** No registered driver answers to that `simlock ` wrapper -- either the name is wrong - * or the platform's driver refused to start. */ UNKNOWN_PASSTHROUGH_TOOL: { readonly tool: string }; - /** - * ADR 0005 §19e: a `device.exec` command outran `exec.timeoutMs` and was killed. No details: - * the timeout that applied is already in the message, and a client that wants the - * configured value reads `config.get`. `domain`, not `transport` -- nothing about the - * connection failed; the command did. - */ EXEC_TIMEOUT: Record; DOCTOR_UNAVAILABLE: Record; NUKE_UNAVAILABLE: Record; INTERNAL: Record; - // -- domain, gateway mode (ADR 0005) -- - /** - * ADR 0005 §34/§23: the operation exists in the contract, this daemon is a gateway, and a - * gateway is not the thing that can answer it. Two populations share the code, and - * `details.operation` is what tells them apart for a caller: - * - * - permanently, by design: `nuke.run`, `cleanup.run`, `doctor.run`, `driver.passthrough` -- - * operations that act on one machine's devices as a whole, which stay per-worker - * (§34: "a fleet-wide destructive command from one endpoint is not something v1 should - * offer", and a passthrough command string the client cannot run is worse than an error); - * - until #118: the lease family (`lease.request`, `lease.renew`, `lease.release`, - * `lease.cancel`, `lease.list`, `lease.release-all`) and `device.exec`, which the fleet - * queue and the forwarding path implement. - * - * It is not `UNKNOWN_REQUEST`: the operation is known, and the answer "ask a worker" is a - * different instruction from "that is not an operation". - */ UNSUPPORTED_IN_GATEWAY_MODE: { readonly operation: string }; /** * ADR 0012 §2: the mirror of `UNSUPPORTED_IN_GATEWAY_MODE`. The operation exists, this daemon @@ -205,20 +143,6 @@ type CodeWithDeclaredDetails = { [Code in SimlockErrorCode]: ErrorDetailsMap[Code] extends Record ? never : Code; }[SimlockErrorCode]; -/** - * Hardening: which error codes are declared, in `ErrorDetailsMap` above, to carry a `details` - * payload onto the wire -- read by `src/http/errors.ts`'s `mapError` so it forwards a - * `DispatchError`'s `.details` only for a code the contract actually documents as carrying one, - * rather than for any `DispatchError` whose `.details` happens to be object-shaped. `.details` - * is `unknown` on `DispatchError` (`dispatch.ts`) -- nothing stops a handler from attaching an - * object to a code that declares none, and without this gate `mapError` would put it on the - * wire anyway. - * - * Deliberately a `Record` rather than a plain array: TypeScript - * requires this object to have *exactly* `CodeWithDeclaredDetails`'s keys, no more and no - * fewer, so adding a details shape to `ErrorDetailsMap` without adding it here (or the reverse) - * is a compile error here -- the two cannot drift apart silently. - */ const CODES_WITH_DECLARED_DETAILS_BY_CODE: Record = { PROTOCOL_VERSION_UNSUPPORTED: true, QUEUE_TIMEOUT: true, @@ -253,12 +177,7 @@ export const CODES_WITH_DECLARED_DETAILS: ReadonlySet = new Se export interface ErrorTableEntry { readonly code: Code; readonly kind: ErrorKind; - /** Guessed/placeholder for codes that have no existing CLI mapping today -- see the module - * comment. Existing values (e.g. `NO_CAPACITY: 11`) are taken verbatim from - * `src/cli/index.ts`'s `DAEMON_ERROR_EXIT_CODES`. */ readonly cliExitCode: number; - /** Same caveat as `cliExitCode` -- existing values taken from `src/http/errors.ts`'s - * `mapError`. */ readonly httpStatus: number; } @@ -370,7 +289,6 @@ export const ERROR_TABLE: { readonly [Code in SimlockErrorCode]: ErrorTableEntry // out of time" outcome (`QUEUE_TIMEOUT`) already uses -- so a script branching on "timed // out" does not need a second number for the second kind of wait. EXEC_TIMEOUT: { code: "EXEC_TIMEOUT", kind: "domain", cliExitCode: 10, httpStatus: 504 }, - // A download that outran its budget: 504 and 10 for the same reason as `EXEC_TIMEOUT`. DOWNLOAD_TIMEOUT: { code: "DOWNLOAD_TIMEOUT", kind: "domain", diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index 2101109ff..e7e75b66a 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -50,7 +50,6 @@ const ROLE_MATRIX: ReadonlyArray<{ }, { name: "token.list", input: {}, role: "admin" }, { name: "token.revoke", input: { id: "tok_1" }, role: "admin" }, - // ADR 0005 §8/§23: gateway-only, admin throughout. { name: "worker.list", input: {}, role: "admin" }, { name: "worker.drain", input: { workerId: "wrk_1" }, role: "admin" }, { name: "worker.undrain", input: { workerId: "wrk_1" }, role: "admin" }, @@ -85,8 +84,6 @@ describe("operation role matrix", () => { }); }); -/** Which calls change state: the daemon log records a `write` at its default level and a - * `read` only at `debug`. Rows keyed like `ROLE_MATRIX`, input-dependent ones twice. */ const EFFECT_MATRIX: ReadonlyArray<{ readonly name: OperationName; readonly input: unknown; diff --git a/src/contract/operations.ts b/src/contract/operations.ts index 320c2200c..63dddd304 100644 --- a/src/contract/operations.ts +++ b/src/contract/operations.ts @@ -614,24 +614,7 @@ export const leaseReleaseAll = defineOperation({ output: z.object({ leaseIds: z.array(z.string()) }), }); -// ---- list.get ----------------------------------------------------------------------------- - -/** - * The union has four arms because two kinds of daemon answer it (ADR 0005 §32): - * - * - a **worker** returns its own registry records -- the full `deviceRecordSchema`, which an - * operator inspecting one machine legitimately wants; - * - a **gateway** returns the fleet, and cannot return that shape: it has never held a - * virtual device's `driverDeviceId` or a driver's private `driverData`, and fabricating either would be a - * lie about a device it does not own. It answers the narrower `statusDeviceSchema` (plus - * `workerId`) instead -- the same devices `status.get` reports. - * - * Arm order is load-bearing: a full record matches the first arm, so a worker's answer is - * unchanged. `rules` is per-machine cleanup configuration, so a gateway answers it with an - * empty list rather than inventing a fleet-wide rule set. `requests` lists the requests waiting - * for a device: a worker's own, or on a gateway the fleet queue's and then each worker's, each - * of those with its `workerId`. - */ +/** Arm order is load-bearing: a full record matches the first arm, so a worker's answer is unchanged. */ // fallow-ignore-next-line unused-export -- consumed only through the OPERATIONS registry, not by name; still public contract surface. export const listGet = defineOperation({ name: "list.get", @@ -962,16 +945,6 @@ export const workerInstallComponent = defineOperation({ output: z.object({ results: z.array(workerInstallResultSchema) }), }); -// ---- component.remove (ADR 0010 §8) --------------------------------------------------------- - -/** - * Removes one component Simlock installed: refused with `COMPONENT_NOT_OWNED` when Simlock has - * no record of installing exactly what is installed now, `COMPONENT_IN_USE` while any device - * uses it, Simlock's or not, and `COMPONENT_BUSY` while an install or removal runs or waits on - * that platform. `dryRun` runs every one of those checks and removes nothing, answering - * `would-remove`. Admin-only. `sizeBytes` is the component's size as listed, when known; - * `residue` says what stayed on disk after a removal and how to reclaim it. - */ // fallow-ignore-next-line unused-export -- consumed only through the OPERATIONS registry, not by name; still public contract surface. export const componentRemove = defineOperation({ name: "component.remove", @@ -993,7 +966,6 @@ export const componentRemove = defineOperation({ const ENROLLED_DEVICE_ID_MAX_LENGTH = 200; -/** An ID as an operator types it: no whitespace or control character, and not an option. */ const enrollmentDeviceIdSchema = z .string() .min(1) @@ -1036,8 +1008,6 @@ export const deviceRemove = defineOperation({ output: z.object({ id: z.string(), driverDeviceId: z.string() }), }); -// ---- the full registry ---------------------------------------------------------------------- - export const OPERATIONS = { "catalog.get": catalogGet, "status.get": statusGet, diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index 5eb680d70..5dd32f62a 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -13,49 +13,11 @@ export interface ProtocolRange { const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().int() }); -/** - * The range this contract's daemon speaks. Both ends are 24, and every move that got it there - * was breaking with no shim kept behind them, which is exactly when ADR 0003 §6's honesty - * rule says a range must *not* widen: ADR 0004 removed `lease.heartbeat` and `mode` from the - * wire (taking it to 4), and ADR 0005 adds `device.exec` and its `output` push family, a - * `mode` field `status.get` now always carries, and the gateway surface -- `worker.*`, - * `workerId`, the `worker` token role -- on top (taking it to 5). ADR 0008 makes the catalog - * carry a required `modelRuntimes` and `modelAliases` per platform (taking it to 6), which a - * gateway reading a worker's catalog depends on. ADR 0007 makes a device's mode a required `mode` - * on the grant, `status.get`, and `list.get` (taking it to 7), then lets a lease request choose - * that mode with `mode`, replacing `full`, and reshapes `config.get`'s `ios` block (taking it to - * 8). ADR 0010 adds `component.install` and its `component-progress` push (taking it to 9): a - * gateway's `worker.install-component` (ADR 0010 §7) relays that operation to its - * workers, so a worker without it must be `incompatible` rather than fail in the middle of a - * relay. ADR 0014 gives every event envelope an `id` (taking it to 10): a worker's pushed events - * without one would fail the gateway's schema, so a worker on 9 is `incompatible` instead. ADR 0009 §7 makes `atRamBudget` a required field of each platform's `status.get` - * capacity (taking it to 11), which a gateway routing on it depends on. ADR 0015 §3 makes `modelClasses` a required field of each platform's catalog (taking it to 12): a gateway reading a worker's catalog would fail its schema without it, so a worker on 11 is `incompatible`. ADR 0015 §4 makes `classDefaults` a required field of each platform's catalog too (taking it to 13), for the same reason: a worker on 12 is `incompatible`. ADR 0009 §6 makes `servesDefaultMode` a required field of each device in `status.get` (taking it to 16), which a gateway telling a warm hit for a request with no mode depends on: a worker on 15 is `incompatible`. ADR 0017 §4 and §8 make `warmPool.reserveRunning` a required field of `config.get`'s output (taking it to 17): a client parsing an older daemon's `config.get` would fail its schema without it, so a daemon on 16 is `incompatible`. While a daemon is `starting`, its `status.get` answers `daemon` and `host` alone, so `devices`, `leases`, `capacity` and `queueDepth` become optional (taking it to 18): a peer on 17 cannot parse that answer, and a gateway reading it from a worker on 17 would fail its schema, so a worker on 17 is `incompatible`. `warmPool.targets` and `warmPool.maxConcurrentBoots` are required fields of `config.get`'s output too (taking it to 19), for the same reason: a daemon on 18 is `incompatible`. `lease.request` takes `leaseId` and every lease record carries `idChosenByRequester` (ADR 0020, taking it to 21): a gateway never forwards a `leaseId` to a worker that would reject it, and reads the flag from every worker's lease list, so a worker on 20 is `incompatible`. `lease.request` takes `fleetRequestId`, which a gateway sets on every dispatch to mark it a probe (ADR 0021, taking it to 22): a worker that does not know the field would refuse the request, so a worker on 21 is `incompatible`. Every device a worker reports carries `physical` (ADR 0022 §1, taking it to 23): a gateway reads it from every worker's devices, and a device without it fails the gateway's schema, so a worker on 22 is `incompatible`. `device.add` and `device.remove` are operations (ADR 0022 §2, taking it to 24): a client on 24 that sends one to a daemon on 23 fails `hello` with `PROTOCOL_VERSION_UNSUPPORTED` instead of the older daemon's `UNKNOWN_REQUEST`, and a status entry may carry `driverDeviceId` for a physical device, so a worker on 23 is `incompatible`. It only ever widens once a second version is actually kept alive side by side with the - * first, which nothing here does. - * - * A client from before any of those changes simply does not overlap this daemon, and `hello` - * fails with `PROTOCOL_VERSION_UNSUPPORTED` naming both ranges; `daemon.stop` stays the frozen - * exception so the upgrade path (stop, then start the new daemon) exists at all. `status.get`'s - * `mode` is the clearest reason this had to move rather than stay at 4: an older client parsing - * that response against a schema without the field is not a compatibility story anyone kept, so - * advertising 4 would be a claim to a compatibility path that does not exist. - * - * The consequence ADR 0005 §31 names: a pre-0005 worker's uplink negotiates nothing, so its - * gateway marks it `incompatible` -- with both ranges on the view -- and never dispatches to it. - * A worker on 5 is marked `incompatible` the same way (ADR 0008 §10), and so is one on 7 - * (ADR 0007 §12), and so is one on 8 (ADR 0010 §9), one on 10 (ADR 0009 §7), one on 11 (ADR 0015 §3), one on 12 (ADR 0015 §4), one on 13 (ADR 0015 §1), one on 14 (ADR 0015 §2), one on 15 (ADR 0009 §6), and one on 16 (`warmPool.reserveRunning`), one on 17 (a starting `status.get`), one on 18 (`warmPool.targets`), and one on 19 (`status.get`'s `warmPool`), and one on 20 (`leaseId`), and one on 21 (`fleetRequestId`), and one on 22 (`physical`), and one on 23 (`device.add`). - */ +/** 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: 24, max: 24 }; -/** - * The one protocol version that ever existed before ranges did. Used only to build the - * `{min:2,max:2}` "daemon range" a client reports when it detects a legacy daemon's old-style - * exact-match mismatch (see `mapLegacyProtocolMismatch`) -- there is no live protocol-2 daemon - * in this repository to negotiate with; this constant documents the historical fact the ADR's - * compatibility note depends on. - */ const LEGACY_DAEMON_PROTOCOL_VERSION = 2; -/** Highest version present in both ranges, or `undefined` if they do not overlap at all. */ export function negotiateProtocolVersion( client: ProtocolRange, daemon: ProtocolRange, diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 08764af18..7b1ef0e3d 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -166,80 +166,21 @@ export const deviceRecordSchema = z }) .superRefine((device, context) => checkDeviceType(device, context, true)); -/** - * The device shape `status.get` returns (ADR §3: `status.get` is `role: "agent"`, with no - * ownership check -- it reports on every device in the registry, not just ones the caller - * leases). Deliberately narrower than `deviceRecordSchema`, for the same reason - * `grantedDeviceSchema` is narrower than it: an agent is not an operator inspecting the - * registry, and `deviceRecordSchema`'s `driverData` is an opaque, driver-defined blob whose - * contents this contract cannot bound -- handing it to every agent for every device - * (including devices other principals hold) is exactly the leak this schema exists to close. - * - * What stays, and why: `status.get` is what `simlock status` renders for a human (ADR §11, - * "Human-readable status ... formatting stays"), and that rendering legitimately surfaces - * device health, not just identity -- - * - * - `id`, `spec`: which device this is. - * - `physical`, `mode`, `class`: which kind of device it is (`mode` for a virtual one, `class` for - * a physical one). - * - `servesDefaultMode`: whether the device's pool is the worker's default mode (ADR 0009 §6). - * - `state`: the human-status line's primary content (`Device : `). - * - `foreignStateDetectedAt`, `foreignProvenanceDetectedAt`: surfaced as "foreign state/ - * provenance change" markers -- an agent legitimately wants to know a device it might be - * about to request was tampered with outside simlock. - * - `quarantineAttempts`, `quarantineNextRetryAt`: surfaced as purge-retry progress for a - * quarantined device, so a caller waiting on capacity can see why a slot isn't freeing up. - * - `transitionAgeMs`: the mid-transition decoration, surfaced as "mid-transition ". - * - `stalled`: the stall decoration, surfaced as "stalled": a caller waiting on capacity can see - * that a slot is held by a transition that is not finishing. - * - * What stays off, and why: `driverDeviceId` of a virtual device (the driver address of a device - * the caller does not hold is not actionable -- a non-owning agent cannot drive it, and a holder - * already gets it on their grant). A physical device differs: its canonical ID is how an operator - * names it to `device remove`, and it names a device on the desk, not a driver address only its - * holder can use, so it is shown to every role. Also off: `driverData` (opaque driver-private blob, unbounded contents), - * `createdAt`/`lastLeaseEndedAt` (internal bookkeeping timestamps the human formatter never - * reads), `recoveringSince`/`recoveryAttempts` (crash-recovery bookkeeping -- `safety.md` rule - * 2 scopes that privilege narrowly, and broadcasting its progress to every agent is not part of - * that scope), `quarantinedAt` (superseded by `quarantineAttempts`/`quarantineNextRetryAt` for - * what a caller needs), and `address` (only meaningful to whoever is driving the device, i.e. - * the lease holder, who gets it on the grant). `mode` stays: which kind of device it is matters - * to anyone reading the fleet. - */ export const statusDeviceSchema = z .object({ id: z.string(), spec: deviceSpecSchema, state: deviceStateSchema, - /** Always present: see `checkDeviceType`. */ physical: z.boolean(), - /** - * The device mode (see `DeviceRecord.mode`) -- not the daemon's own `worker`/`gateway` mode. - * Present exactly when `physical` is `false`. - */ mode: deviceModeSchema.optional(), - /** Present exactly when `physical` is `true`. */ class: physicalClassSchema.optional(), driverDeviceId: z.string().optional(), - /** - * ADR 0009 §6: whether the device's pool mode (its spec's mode, full when it names none) is the - * worker's default mode for the device's platform -- the pool a request naming no mode draws - * from. The gateway reads it to tell a warm hit for such a request. Not `mode`: `mode` is what - * the device actually is, this is what a request with no mode would get. - */ servesDefaultMode: z.boolean(), foreignStateDetectedAt: z.number().optional(), foreignProvenanceDetectedAt: z.number().optional(), quarantineAttempts: z.number().optional(), quarantineNextRetryAt: z.number().optional(), - /** Decoration added by `status.get`; absent for a device not mid-transition. */ transitionAgeMs: z.number().optional(), - /** - * Decoration added by `status.get`: `true` for a device stuck `provisioning` or `reclaiming` - * past its threshold, by the rule `doctor`'s `stalled-transition` finding uses; absent for - * every other device. A worker view copies it, so a gateway reports a worker's stall as the - * worker does. - */ stalled: z.boolean().optional(), /** * ADR 0005 §20: which worker this device lives on. Additive and gateway-only -- a worker @@ -258,25 +199,6 @@ export const statusDeviceSchema = z return rest; }); -/** - * The device a `lease.request`/`lease.renew` grant carries -- deliberately narrower than - * `deviceRecordSchema` (ADR 0003 §1: "the daemon maps [core records] onto contract types in - * exactly one place"). A grant is agent-facing, not an admin inspection of the registry, so it - * carries only what a caller needs to drive the device it was just handed: - * - * - `id`: the lease-facing device identity (what `lease.list`/`lease.renew` correlate against). - * - `driverDeviceId`: the driver address a caller actually drives (simctl UDID / adb serial). - * - `spec`, `address`, `mode`: same meanings as on `DeviceRecord` (src/core/domain.ts). - * - `physical`, `class`: which kind of device it is; `class` is optional on a grant. - * - * Everything else on `DeviceRecord` -- `driverData` (opaque driver-private blob), `state`, - * `createdAt`, every `quarantine*`/`foreign*`/`recovering*` field, and the `status`/`list` - * decoration's `transitionAgeMs` -- is internal reclamation/health bookkeeping a grant recipient - * has no business seeing, and stays off this shape. `list.get` (admin-only) still returns the - * full `deviceRecordSchema` -- an operator inspecting the registry legitimately wants the whole - * record. `status.get` (agent-role, ADR §3) is narrower still -- see `statusDeviceSchema` below, - * which is what it actually returns. - */ export const grantedDeviceSchema = z .object({ id: z.string(), diff --git a/src/core/create-core.ts b/src/core/create-core.ts index 3c75d91f5..b0cb99323 100644 --- a/src/core/create-core.ts +++ b/src/core/create-core.ts @@ -109,11 +109,8 @@ class CorePortMissingError extends Error { /** Device management, built and ready except for the ports `connect` fills. */ export interface Core { readonly capacity: CapacityCoordinator; - /** Read-only capacity view for status, and for the daemon's status reporting. */ readonly capacityReader: CapacityReader; - /** The warm pool's figures, for status and doctor. */ readonly warmPoolReader: WarmPoolReader; - /** The device catalog and the passthrough resolver, both answered by the driver catalog. */ readonly catalog: CatalogReader & PassthroughResolver; readonly claims: DeviceOperationClaims; /** @@ -136,17 +133,7 @@ export interface Core { readonly registry: Registry; /** Supplies the ports leasing implements. Call it once, before any request is admitted. */ connect(ports: CorePorts): void; - /** - * Startup's one read of the machine: every driver's `listManaged` once, each platform bounded - * by a fixed limit. Never throws; a platform that could not be listed is left out of it. - */ readStartup(): Promise; - /** - * The device steps of startup: quarantine restore, interrupted reclaims, spent devices, none of - * them on a platform the read could not list; then it starts the capacity observer and the warm - * pool, so the facts convergence commits trigger no pass and the first one follows - * `daemon.started`, where a lowered `maxRunning` is converged. - */ converge(read: StartupRead): Promise; /** * Runs a warm pool pass now. Leasing calls it once a backgrounded reclaim has given up its @@ -378,7 +365,6 @@ export function createCore(options: CoreOptions): Core { async converge(read) { await startup.converge(read); warmPool.start(); - // Every run begins with a step in the figures: what they are now. capacityObserver.start(); }, async passWarmPool() { diff --git a/src/core/device-operation-claims.ts b/src/core/device-operation-claims.ts index cd0b3b73a..8850696e9 100644 --- a/src/core/device-operation-claims.ts +++ b/src/core/device-operation-claims.ts @@ -1,13 +1,3 @@ -/** - * Operations that must not overlap for a single device: booting, eviction, - * cleanup, nuke, the lease-scoped crash `recovery` reboot, and a backgrounded - * `reclaim` -- the last marks the device as a live, in-process operation so - * `StartupConverger#recoverInterruptedReclaims` (which only recovers *unclaimed* - * `reclaiming` devices) never mistakes it for one left over from a previous crash, - * and so `simlock doctor` never reads a long-but-healthy erase as a stalled - * transition. Every release takes one, since none of them wait for the purge. - * See `LeaseReleaseCoordinator#reclaimInBackground`. - */ export type DeviceOperation = | "boot" | "eviction" @@ -17,15 +7,9 @@ export type DeviceOperation = | "reclaim" | "enrollment"; -/** An exclusive, idempotently releasable claim for one device operation. */ export interface DeviceOperationClaim { readonly deviceId: string; readonly operation: DeviceOperation; - /** - * The waiter this claim was taken for, absent when none was: the only ownerless `boot` claim is - * the warm pool's (a failed boot's destroy takes `cleanup`, a fence takes its waiter's id). A - * request waits only for a `boot` nobody owns, never for the one another request is making. - */ readonly owner?: string | undefined; release(): void; } diff --git a/src/core/driver.ts b/src/core/driver.ts index f906f2768..6c82c6dc9 100644 --- a/src/core/driver.ts +++ b/src/core/driver.ts @@ -11,26 +11,11 @@ import type { VirtualSpec, } from "./domain.js"; -/** - * What a driver resolves (ADR 0015 §5): a request with the exact model the core settled on. A - * driver never sees a class. - */ export interface ExactDeviceRequest { readonly platform: Platform; readonly model: string; readonly osVersion?: string; - /** - * The device mode the request asked for; absent when it named none (ADR 0007 §1). Transports - * carry it as it arrived. `LeaseAcquisitionCoordinator` is the one place an absent mode gets - * the worker's default, and a driver's `resolveSpec` always receives it resolved. - */ readonly mode?: DeviceMode; - /** - * The image type the request names, as the driver's catalog lists it; absent when it names - * none. Transports carry it as it arrived. Only a driver knows what a tag is: one that has none - * throws `UnsupportedRequestOptionError`, and one that has them resolves only to an installed - * image of that tag, never to a download. - */ readonly imageTag?: string; } @@ -240,22 +225,12 @@ export interface PrerequisiteCheck { check(): Promise; } -/** - * One platform tool a driver uses and the version installed (ADR 0008 §6). `name` and the - * version format are the driver's own; the core reports them and reads neither. - */ export interface DriverToolVersion { readonly name: string; readonly version: string; - /** A build identifier the tool reports beside its version, when it has one. */ readonly build?: string; } -/** - * Why a driver refuses to enrol a device (ADR 0022 §2): every `DEVICE_NOT_ENROLLABLE` reason - * except `enrolled-on-other-platform`, which only the core can know. `virtual-device` is a - * driver's too, for any virtual device it reads that is not Simlock's own. - */ export type DriverRefusalReason = | "not-connected" | "network-only" @@ -267,11 +242,6 @@ export type DriverRefusalReason = | "unreadable" | "virtual-device"; -/** - * What a driver read of a physical device (ADR 0022 §5). `driverDeviceId` is the canonical ID - * whatever ID was asked. `apps` are the app IDs the enrolment app list holds; which apps, per - * platform, is the driver's. A refusal carries the driver's reason and its own message. - */ export type PhysicalInspection = | { readonly enrollable: true; @@ -289,11 +259,6 @@ export type PhysicalInspection = export interface Driver { readonly platform: Platform; - /** - * ADR 0015 §4: the model names this driver's platform offers for each class, newest first. - * The core merges it behind the operator's own list and holds no model name itself; a class - * with no list has no entry. - */ readonly defaultModels: Readonly>>; /** * Absolute path of the root this driver owns and scopes every platform command to. @@ -406,12 +371,6 @@ export interface Driver { shutdown(device: VirtualDriverDevice): Promise; destroy(device: VirtualDriverDevice): Promise; listManaged(): Promise; - /** - * Reads a physical device by whatever ID an operator typed, for `device add` (ADR 0022 §5). A - * read: it changes nothing on the device. A device it cannot enrol is a refusal with the - * driver's reason and message, and so is a read that failed or timed out (`unreadable`, naming - * the read): the core relays it and a throw is a driver bug. - */ inspectPhysical(id: string): Promise; /** Read-only: must never trigger a runtime / system-image download. */ listCatalog(): Promise; diff --git a/src/core/fake-driver.ts b/src/core/fake-driver.ts index 0bc2f7dd8..82c2c3dbc 100644 --- a/src/core/fake-driver.ts +++ b/src/core/fake-driver.ts @@ -71,17 +71,11 @@ export interface FakeDriverCall { export interface FakeDriverOptions { readonly availableOsVersions?: readonly string[]; readonly clock: Clock; - /** What `componentFootprint` states. Defaults to nothing to reserve, at the device root. */ readonly componentFootprint?: { readonly path: string; readonly bytes: number }; - /** The percentages every `installComponent` reports through `onProgress`, in order. */ readonly installProgress?: readonly number[]; - /** What `listComponents` reports as each version's size; absent for a version left out. */ readonly componentSizes?: Readonly>; - /** What `listComponents` reports as each version's foreign devices; 0 for one left out. */ readonly foreignDevices?: Readonly>; - /** What every `removeComponent` reports as left on disk; nothing unless set. */ readonly removalResidue?: string; - /** Stands in for a real driver's owned root; nothing here validates or creates it. */ readonly deviceRoot?: string; readonly estimateMs?: Partial>; /** @@ -129,11 +123,6 @@ export interface FakeDriverOptions { * two fakes in one catalog do not both answer to the same tool. */ readonly passthroughTool?: string; - /** - * Builds the scoped command for that tool. Throw `PassthroughRefusedError` from here to - * model a driver's own refusal rules; omit it and every argument list is refused, which - * is what a driver claiming a tool it cannot build a command for would mean. - */ readonly passthrough?: ( args: readonly string[], context?: PassthroughContext, @@ -162,7 +151,6 @@ export class FakeDriver implements Driver { readonly #defaultModels: FakeDriverOptions["defaultModels"]; readonly deviceRoot: string; readonly #availableOsVersions: Set; - /** One receipt per installed version; an install replaces it with a new one. */ readonly #receipts: Map; #installCount = 0; readonly #install: Pick< diff --git a/src/core/quarantine-coordinator.ts b/src/core/quarantine-coordinator.ts index c413d72e8..6c77a395e 100644 --- a/src/core/quarantine-coordinator.ts +++ b/src/core/quarantine-coordinator.ts @@ -26,14 +26,12 @@ export interface QuarantineRegistry { export interface QuarantinePurgeFailure { readonly deviceId: string; readonly leaseId: string; - /** A reclaim strategy, or `delete` for a spent fresh device's failed lease-end shutdown or delete. */ readonly attemptedStrategy: "erase" | "snapshot" | "wipe" | "delete"; readonly duration: number; readonly error: string; } export interface QuarantineRetryConfig { - /** Failed retries allowed after the triggering purge failure before giving up. */ readonly maxRetries: number; readonly retryBackoffMs: number; readonly retryBackoffMultiplier: number; @@ -52,24 +50,6 @@ export interface QuarantineCoordinatorOptions { readonly logger?: Logger; } -/** - * Owns the quarantine lifecycle: entry (release-time purge failure via `enter()`, - * stalled-transition timeout via `enterFromStalledTransition()`), retry, and give-up. - * `quarantined` is the shared "present in the registry, not grantable" disposition (for a - * virtual device it also counts against running capacity; a physical device never counts) (see domain.ts): AcquisitionPlanner and - * the idle-order eviction helpers already select targets by exact state, so a - * quarantined device is invisible to every one of them with no special-casing - * required. - * - * A quarantined virtual device retries its purge on a Clock-driven backoff until it - * either succeeds (the device rejoins the warm pool) or exhausts - * `config.maxRetries`. A spent fresh device (see `mayBeGranted`) retries its - * delete instead, never a purge, and success deletes it. Either way, exhausting - * `config.maxRetries`, at which point it is destroyed -- never merely shut - * down, since `shutdown` is a state AcquisitionPlanner treats as reusable - * warm inventory (`boot-shutdown`), which would silently reintroduce the - * exact dirty-device bug this coordinator exists to prevent. - */ export class QuarantineCoordinator { readonly #timers = new Map(); #disposed = false; diff --git a/src/daemon-protocol/index.test.ts b/src/daemon-protocol/index.test.ts index f4d9da730..5752af9e3 100644 --- a/src/daemon-protocol/index.test.ts +++ b/src/daemon-protocol/index.test.ts @@ -9,16 +9,6 @@ import { describe("daemon protocol", () => { it("keeps the current protocol version and newline framing", () => { - // ADR 0005: protocol 5, no compatibility shim -- `device.exec`, its `output` push family, - // `status.get`'s always-present `mode`, and the gateway surface (`worker.*`, `workerId`, - // the `worker` token role) all land on the wire with nothing kept behind them, exactly as - // ADR 0004's removals took it to 4, so under ADR 0003 §6's honesty rule the range does not - // widen to keep speaking 4. ADR 0008 then makes the catalog's `modelRuntimes` required, - // taking it to 6, and ADR 0007 makes a device's mode a required `mode`, taking it to 7, then - // lets a lease request choose it, taking it to 8, each the same way. ADR 0010 adds - // `component.install` and its `component-progress` push, which a gateway relays to its - // workers, taking it to 9. ADR 0014 gives every event envelope an `id`, taking it to 10. ADR 0009 §7 makes `atRamBudget` a - // required field of each platform's `status.get` capacity, taking it to 11, then ADR 0015 §3 made `modelClasses` a required field of each platform catalog, taking it to 12, then ADR 0015 §4 made `classDefaults` a required field of each platform catalog too, taking it to 13, then ADR 0015 §1 made a lease request's `model` optional beside a new `class`, taking it to 14, then ADR 0015 §2 let `osVersion` be a range, taking it to 15, then ADR 0009 §6 made `servesDefaultMode` a required field of each device in `status.get`, taking it to 16, then `warmPool.reserveRunning` became a required field of `config.get`, taking it to 17, then a starting `status.get` made `devices`, `leases`, `capacity` and `queueDepth` optional, taking it to 18, then `warmPool.targets` and `warmPool.maxConcurrentBoots` became required fields of `config.get`, taking it to 19, then `status.get` gained `warmPool`, taking it to 20, then `lease.request` gained `leaseId` and every lease record `idChosenByRequester`, taking it to 21, then `lease.request` gained `fleetRequestId`, which a gateway sets on every dispatch, taking it to 22, then every device a worker reports gained `physical`, taking it to 23, then `device.add` and `device.remove` joined the operations and a physical device's status entry gained `driverDeviceId`, taking it to 24. expect(DAEMON_PROTOCOL_VERSION).toBe(24); expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n'); }); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 0bdb670c9..e8417e9cc 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -144,34 +144,22 @@ export interface DaemonServerEngineOptions { readonly warmPool: WarmPoolReader; readonly deviceModes: DeviceModeReader; readonly catalog: CatalogReader; - /** The one component installer (ADR 0010 §3), threaded into the `Dispatcher` for - * `component.install`, `component.list`, `component.remove` and `status.get`'s installs; the same - * instance leasing downloads through. */ readonly components: Pick; readonly doctor?: Doctor; - /** What `events.replay` answers from; see `EventHistory`. */ readonly eventHistory: Pick; readonly leases: LeaseCommands; - /** `status.get`'s host block (ADR 0008 §5); see `DispatcherOptions.hostFacts`. */ readonly hostFacts: () => HostFacts; - /** This daemon's instance id; `worker.list` answers with it (see `DispatcherOptions`). */ readonly instanceId: string; readonly queue: QueueControl; readonly reaper: CleanupReaper; readonly healthMonitor?: LeaseHealthMonitor | undefined; readonly enrollment?: DispatcherOptions["enrollment"]; readonly nuke?: Nuke; - /** Builds the scoped command behind `simlock simctl` / `simlock adb`; absent in tests that never use them. */ readonly passthrough?: PassthroughResolver; - /** Runs what `device.exec` resolves (ADR 0005 §19a); absent in tests that never exec. */ readonly processRunner?: ProcessRunner; - /** Base environment for a `device.exec` child, before the driver's scoping keys (see - * `DispatcherOptions.execEnv`). */ readonly execEnv?: NodeJS.ProcessEnv; readonly registry: Registry; - /** What a device's `stalled` flag is worked out from; see `DispatcherOptions.stalls`. */ readonly stalls?: DispatcherOptions["stalls"]; - /** ADR 0003 §11: threaded straight into the `Dispatcher` for `token.create|list|revoke`. */ readonly tokens?: TokenStore; } @@ -286,15 +274,7 @@ export type DaemonServerOptions = type DaemonHealth = "starting" | "running" | "failed"; -/** - * Builds the one shared `Dispatcher` (ADR 0003 §2) from this server's options. Extracted from - * the constructor rather than inlined there because almost all of it is the same conditional - * spread repeated per optional dependency -- `exactOptionalPropertyTypes` means an absent - * option must be *absent*, not `undefined` -- and that list grows with every operation that - * needs a new dependency (`device.exec` added two). The two closures the dispatcher needs from - * the server's own live state are passed in rather than reached for. - */ -// fallow-ignore-next-line complexity -- one conditional spread per optional dependency, as the comment above says. +// fallow-ignore-next-line complexity -- one conditional spread per optional dependency. function buildDispatcher( options: DaemonServerCommonOptions & DaemonServerEngineOptions, hooks: { @@ -312,9 +292,7 @@ function buildDispatcher( components: options.components, config: options.config, ...(options.doctor === undefined ? {} : { doctor: options.doctor }), - // The `operation` log line's error code: the same classifier this server answers with. errorCode: classifyError, - // `worker.list` drops its kept catalog when a component is installed or removed. eventBus: options.eventBus, eventHistory: options.eventHistory, health: hooks.health, @@ -1203,17 +1181,6 @@ export class DaemonServer { } } - /** Builds the ADR 0003 §2/§4 session for one dispatched call from this connection's fixed - * principal and role -- all that is left of it, since ADR 0004 removed the per-connection - * lease state that used to travel alongside. `onProgress` is unset here: only `#requestLease` - * needs one, and builds its own session inline so the closure can see that specific call's - * `requestId`. - * - * `isGatewayUplink` (ADR §27a, H3, round 3 review; narrowed further, see the `Connection` - * field's own doc) reads `connection.isGatewayUplink` directly, not `connection.role` and not - * `connection.forcedRole`: `role` itself is always `"admin"` for such a connection, but so is - * an ordinary HTTP `operator` token's, which is the very confusion this field exists to stop - * propagating into `lease.request`'s `owner` gate. */ #session(connection: Connection): DispatchSession { return { isGatewayUplink: connection.isGatewayUplink, diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index ebdeb9b65..2c7199a2d 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -891,10 +891,6 @@ describe("GatewayDispatcher", () => { it("forwards lease.request through the fleet coordinator and answers UNKNOWN_LEASE for renew/release/exec against an id it never issued", async () => { const { dispatcher } = harness(); - // No worker connected at all: a `noWait` request has nowhere to go and is refused - // immediately, rather than the pre-#118 permanent `UNSUPPORTED_IN_GATEWAY_MODE`. A plain - // `DispatchError("NO_CAPACITY", ...)` (H9, round 2 review) -- no gateway-specific error - // class for `daemon/error-code.ts` to import and recognize. await expect( dispatcher.dispatch( "lease.request", diff --git a/src/gateway/dispatcher.ts b/src/gateway/dispatcher.ts index e4bc26ede..1191fa956 100644 --- a/src/gateway/dispatcher.ts +++ b/src/gateway/dispatcher.ts @@ -195,7 +195,7 @@ export class GatewayDispatcher { "component.remove", "component.remove removes a component from one machine; run it against a worker", ), - // ADR 0022 §2: a device is plugged into one machine, and a gateway asks no worker. + // A device is plugged into one machine, and a gateway asks no worker. "device.add": unsupportedByDesign( "device.add", "device.add enrols a device plugged into one machine; run it on the worker the device is plugged into", @@ -223,9 +223,6 @@ export class GatewayDispatcher { ): Promise> { return runDispatch(operation, rawInput, session, { handlers: this.#handlers, - // #118: `ownsLease`/`device.exec`'s own hook and `lease.cancel`'s resolve these against - // the fleet's own lease index and pending-request queue -- the same shape a worker's own - // `Dispatcher` supplies from its registry and queue, read through `coordinator` instead. authorizeLookups: { leaseRequesterId: (id) => this.options.coordinator.leaseRequesterId(id), ownerId: (id) => this.options.coordinator.ownerId(id), diff --git a/src/http/app.ts b/src/http/app.ts index 758596560..f6676680a 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -141,9 +141,6 @@ const execBodySchema = z }) .strict(); -/** Shape only: what a valid platform, version and worker list are is `component.install`'s and - * `worker.install-component`'s own contract schemas' answer, reached through the dispatcher like - * every other transport's. `workers` present is what picks the gateway operation. */ const componentInstallBodySchema = z .object({ platform: z.string(), @@ -152,10 +149,8 @@ const componentInstallBodySchema = z }) .strict(); -/** Shape only: the platform and the ID's bounds are `device.add`'s own contract schema's answer. */ const deviceAddBodySchema = z.object({ id: z.string(), platform: z.string() }).strict(); -/** A route's JSON body, validated against its schema; a body that fails is `400 BAD_REQUEST`. */ function jsonBody(schema: Schema) { return zValidator("json", schema, (result, c) => { if (!result.success) { @@ -287,8 +282,6 @@ export function createHttpApp(deps: HttpGatewayDeps): Hono & HttpAppDisposa return c.json(result); }); - // ADR 0010 §8: an admin removal, answered as one JSON body. Which platforms and versions are - // valid, and who may remove, are `component.remove`'s own answers through the dispatcher. app.delete("/v1/components/:platform/:version", agentAuth, async (c) => { const dryRun = c.req.query("dryRun"); if (dryRun !== undefined && dryRun !== "true" && dryRun !== "false") { @@ -306,8 +299,6 @@ export function createHttpApp(deps: HttpGatewayDeps): Hono & HttpAppDisposa return c.json(result); }); - // ADR 0022 §2: an admin enrols and unenrols a device plugged into this host. The role check, - // the bounds on `id` and what a refusal says are the operations' own, through the dispatcher. app.post("/v1/devices", agentAuth, jsonBody(deviceAddBodySchema), async (c) => { const result = await deps.dispatch( "device.add", @@ -329,7 +320,6 @@ export function createHttpApp(deps: HttpGatewayDeps): Hono & HttpAppDisposa app.post("/v1/lease-requests", agentAuth, jsonBody(leaseRequestBodySchema), async (c) => { const identity = c.get("identity"); const body = c.req.valid("json"); - // Bounds and shape are `lease.request`'s own (`idempotencyKey`), checked by the dispatcher. const idempotencyKey = c.req.header("Idempotency-Key"); const outcome = await tracker.submit(identity, toLeaseRequestInput(body), idempotencyKey); if (outcome.kind === "rejected") { diff --git a/src/simlock-client/client.test.ts b/src/simlock-client/client.test.ts index 40b581f08..6a0007573 100644 --- a/src/simlock-client/client.test.ts +++ b/src/simlock-client/client.test.ts @@ -56,14 +56,9 @@ describe("connectSimlock: handshake", () => { daemonVersion: "9.9.9", }); - // The daemon deliberately keeps this connection open after a failed range negotiation - // (ADR §6, see the comments in `daemon/server.ts#handleHello`) so an admin client can - // still send `daemon.stop` on it instead of restarting the daemon -- closing here, what - // this client used to do unconditionally, made that escape hatch dead on arrival. const client = await connectPromise; expect(connection.closed).toBe(false); - // Every other operation rejects with the captured mismatch error, never reaching the wire. const before = connection.sent.length; await expect(client.getStatus()).rejects.toMatchObject({ code: "PROTOCOL_VERSION_UNSUPPORTED", diff --git a/src/simlock-client/client.ts b/src/simlock-client/client.ts index f903d2825..265b47c3b 100644 --- a/src/simlock-client/client.ts +++ b/src/simlock-client/client.ts @@ -287,7 +287,7 @@ export interface SimlockAdminClient extends SimlockClient { removeComponent(input: ComponentRemoveInput): Promise; /** - * ADR 0022 §2: enrols the physical device plugged into the host this daemon runs on, named by + * Enrols the physical device plugged into the host this daemon runs on, named by * any ID the platform's driver reads. A device it cannot enrol is `DEVICE_NOT_ENROLLABLE` with * the driver's reason; a leased, reclaiming or busy one is `DEVICE_BUSY`. A device already * enrolled is enrolled again: its record is kept and its model, OS version, class and apps are @@ -296,17 +296,13 @@ export interface SimlockAdminClient extends SimlockClient { addDevice(input: DeviceAddInput): Promise; /** - * ADR 0022 §2: forgets an enrolled physical device, named by its canonical ID. Nothing is sent + * Forgets an enrolled physical device, named by its canonical ID. Nothing is sent * to the device. `UNKNOWN_DEVICE` for an ID with no enrolled device, `DEVICE_BUSY` for a leased * or reclaiming one. A gateway answers `UNSUPPORTED_IN_GATEWAY_MODE`. */ removeDevice(input: DeviceRemoveInput): Promise; } -/** Internal: builds either client. `admin` toggles only which methods the returned object - * carries (an object literal with/without the extra methods) -- the daemon's own role check is - * what actually stops an agent-role session from calling one of them; this flag exists purely - * so `simlock/client` doesn't even show the admin methods in a caller's editor. */ export async function connectSimlockClient( options: ConnectOptions, admin: false, @@ -447,14 +443,6 @@ async function resolveConnection(options: ConnectOptions): Promise void>(); @@ -687,18 +675,6 @@ class SimlockClientImpl { return this.#call("device.remove", input); } - // ---- abort (ADR §10) ------------------------------------------------------------------------ - - /** - * The four behaviours, all funneled through one function once we know the request was sent - * and a signal was given: - * - * - already resolved by the time abort fires -> `#requestDone` guards the raw promise's own - * `.then` from resolving twice; the abort handler itself never runs any cancellation logic - * because `#requestDone` is also checked there. - * - queued / in-flight / not-found are all resolved by `#cancelOnAbort`, driven entirely by - * `lease.cancel`'s own three-way result. - */ #requestLeaseWithAbort( requestPromise: Promise, signal: AbortSignal, diff --git a/src/simlock-client/types.ts b/src/simlock-client/types.ts index 21b32b7e7..8c64730b4 100644 --- a/src/simlock-client/types.ts +++ b/src/simlock-client/types.ts @@ -79,8 +79,6 @@ export type WorkerDrainOutput = OpOutput<"worker.drain">; export type WorkerUndrainOutput = OpOutput<"worker.undrain">; export type WorkerRemoveOutput = OpOutput<"worker.remove">; -// ---- components (ADR 0010 §6) --------------------------------------------------------------- - export type ComponentInstallInput = OpInput<"component.install">; export type ComponentInstallOutput = OpOutput<"component.install">; export type ComponentListInput = OpInput<"component.list">; @@ -94,8 +92,6 @@ export type DeviceAddOutput = OpOutput<"device.add">; export type DeviceRemoveInput = OpInput<"device.remove">; export type DeviceRemoveOutput = OpOutput<"device.remove">; -// ---- pushes --------------------------------------------------------------------------------- - export type LeaseProgress = z.infer; export type EventPush = z.infer<(typeof PUSH_SCHEMAS)["event"]>; export type ComponentInstallProgress = z.infer< diff --git a/ui/src/views/workers.test.tsx b/ui/src/views/workers.test.tsx index ad82688cd..773da998f 100644 --- a/ui/src/views/workers.test.tsx +++ b/ui/src/views/workers.test.tsx @@ -85,7 +85,6 @@ describe("the workers views", () => { idChosenByRequester: false, ttlMs: 185_000, }; - // The worker answered 10 seconds ago, when the provisioning device was 4 seconds in. const view = worker({ devices: [leased, provisioning, ready], lastSeenAt: NOW - 10_000, @@ -135,10 +134,8 @@ describe("the workers views", () => { new ApiError(501, "UNSUPPORTED_IN_WORKER_MODE", "worker.drain is not supported"), ); expect(text(unsupported)).toBe("Not available on a single host."); - // Not shown as an error. expect(unsupported).not.toContain('role="alert"'); - // Any other refusal is an error, with the daemon's message. const failed = render(new ApiError(500, "INTERNAL", "Internal error")); expect(failed).toContain('role="alert"'); expect(text(failed)).toBe("Internal error"); From 2805f3da05a1acd890d91bead69dbeeb1e020548 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 11:12:25 +0200 Subject: [PATCH 16/22] test: a stopped simulator is refused unenrol; the guard is what refuses it (#437) --- src/core/registry.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/core/registry.test.ts b/src/core/registry.test.ts index c9e1e63d9..3838e3e02 100644 --- a/src/core/registry.test.ts +++ b/src/core/registry.test.ts @@ -2882,7 +2882,20 @@ describe("Registry: device.deleted carries physical, and enrollment writes (ADR await expect(registry.reenrollPhysical(device.id, input)).rejects.toThrow(RegistryEventError); await expect(registry.unenrollPhysical(device.id)).rejects.toThrow(RegistryEventError); - await expect(registry.unenrollPhysical(virtual.id)).rejects.toThrow(RegistryEventError); + await expect(registry.unenrollPhysical(virtual.id)).rejects.toThrow( + `Cannot unenrol device: ${virtual.id}`, + ); + const stopped = await readyVirtual(registry, "d2"); + await registry.transitionDevice(stopped.id, "shutdown", { + event: "device.shutdown", + payload: { deviceId: stopped.id, initiator: "test" }, + }); + await expect(registry.unenrollPhysical(stopped.id)).rejects.toThrow( + `Cannot unenrol device: ${stopped.id}`, + ); + expect(registry.snapshot.devices.find((entry) => entry.id === stopped.id)?.state).toBe( + "shutdown", + ); await expect(registry.reenrollPhysical(virtual.id, input)).rejects.toThrow( `Cannot re-enrol device: ${virtual.id}`, ); From fff6d5d2497b6c5f4020964a8be65a88d09e40b9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 11:25:37 +0200 Subject: [PATCH 17/22] fix: tests prove emit-after-commit; one place decides re-enrol busy; comments and docs true of the code (#437) Review findings: 2 test titles, 1 duplicated decision, 12 claims. --- docs/CLI.md | 14 +++++++------- docs/CLIENT.md | 2 +- docs/internal/COMPONENTS.md | 2 +- src/contract/schemas.ts | 6 ++++++ src/core/create-core.ts | 3 +-- src/core/physical-enrollment.test.ts | 17 +++++++++++++++++ src/core/physical-enrollment.ts | 3 +++ src/core/quarantine-coordinator.test.ts | 4 ++-- src/core/quarantine-coordinator.ts | 9 +++++---- src/core/registry.test.ts | 12 +++++++++--- src/core/registry.ts | 16 ++++++++++++---- src/daemon/dispatcher.ts | 5 +---- 12 files changed, 65 insertions(+), 28 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 46fe8a165..7ead7f749 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -73,7 +73,7 @@ command starts it again) to bring the platform up. | 10 | `EXEC_TIMEOUT` | a `simctl`/`adb` command run through `device.exec` outlived `exec.timeoutMs` and was killed | | 10 | `DOWNLOAD_TIMEOUT` | a runtime download, including the time spent waiting for another download on the same platform, outlived `downloads.timeoutMs` | | 11 | `NO_CAPACITY` | capacity reached and `--no-wait` was set, or, through a gateway, no worker that takes requests can serve the request | -| 11 | `DEVICE_BUSY` | `device add` or `device remove` of a device that is leased, being reclaimed or held by another operation, such as an enrolment or a quarantine retry; try again once that ends | +| 11 | `DEVICE_BUSY` | `device add` or `device remove` of a device that is leased, being reclaimed or held by another operation, such as another `device add`; try again once that ends | | 11 | `COMPONENT_BUSY` | `component remove` while a component install or removal runs or waits on that platform; try again once it ends | | 12 | `NO_DRIVER` | no driver registered for the requested platform | | 12 | `RUNTIME_MISSING` | runtime not installed and no `--allow-download`, or no installed image of the `--image-tag` asked for | @@ -911,10 +911,11 @@ install ` resolve their path on the **worker's** filesystem, so the artifact has to be there already (a shared volume, a CI checkout on that machine). -**Five commands refuse outright**, with `UNSUPPORTED_IN_GATEWAY_MODE` (exit -2): `simlock nuke`, `simlock cleanup`, `simlock doctor`, `simlock device add` -and `simlock device remove`. The first three act on one -machine's devices as a whole, and the last two name a device plugged into one +**Seven commands refuse outright**, with `UNSUPPORTED_IN_GATEWAY_MODE` (exit +2): `simlock nuke`, `simlock cleanup`, `simlock doctor`, `simlock component +list`, `simlock component remove`, `simlock device add` and `simlock device +remove`. The first five act on one machine's devices or components as a +whole, and the last two name a device plugged into one worker; in v1 all of them stay per-worker, direct operations — run them against that worker's own daemon. A fleet-wide destructive command from one endpoint is not something v1 offers. @@ -1808,8 +1809,7 @@ add again gets a new record. `enrolled-on-other-platform`. The message is the driver's own, and says what to do. - `DEVICE_BUSY` (exit 11): the device is `leased`, is `reclaiming`, or - another operation holds it, such as a `device add` or a quarantine retry - (`busy`). + another operation holds it, such as another `device add` (`busy`). - `NO_DRIVER` (exit 12): this machine has no driver for the platform. `device remove` takes the canonical ID, which `simlock status --json` and diff --git a/docs/CLIENT.md b/docs/CLIENT.md index 6b578c187..7550986a1 100644 --- a/docs/CLIENT.md +++ b/docs/CLIENT.md @@ -505,7 +505,7 @@ const device = await admin.addDevice({ platform: "ios", id: "00008110-001A2B3C4D // model: "iPhone 15 Pro", osVersion: "18.4", class: "phone", appCount: 12, // reenrolled: false } await admin.removeDevice({ id: device.driverDeviceId }); -// { id: "00008110-001A2B3C4D5E", driverDeviceId: "00008110-001A2B3C4D5E" } +// { id: "dev_8f2a", driverDeviceId: "00008110-001A2B3C4D5E" } ``` - `id` in `addDevice`'s result is the registry id; `driverDeviceId` is the diff --git a/docs/internal/COMPONENTS.md b/docs/internal/COMPONENTS.md index d06e9e8cd..9bebad238 100644 --- a/docs/internal/COMPONENTS.md +++ b/docs/internal/COMPONENTS.md @@ -61,7 +61,7 @@ pool reads from acquisition. |---|---|---|---| | `Registry` | `src/core/registry.ts` | Devices (virtual in `devices`, physical in `physicalDevices`, ADR 0022 §1), leases, lease requests and component records, written through one commit to `state.json`; the one virtual read, `snapshot.virtualDevices`; the device transition function's only caller; emits the post-commit device and lease facts. | Decide a transition: callers do, inside a decision section. | | `SerializedDecision` | `src/core/serialized-decision.ts` | Serialising short read-decide-commit sections. | Hold driver work or other long I/O. | -| `DeviceOperationClaims` | `src/core/device-operation-claims.ts` | Exclusive per-device operation claims: boot, eviction, cleanup, nuke, reclaim, enrollment. | Any lifecycle, cleanup or leasing policy. | +| `DeviceOperationClaims` | `src/core/device-operation-claims.ts` | Exclusive per-device operation claims: boot, eviction, cleanup, nuke, recovery, reclaim, enrollment. | Any lifecycle, cleanup or leasing policy. | | `PhysicalEnrollment` | `src/core/physical-enrollment.ts` | `device add` and `device remove` (ADR 0022 §2): refusing Simlock's own simulators and emulators, relaying the driver's read and its refusals, finding the one record a canonical ID has, taking the `enrollment` claim from the second read to the rewrite, and the busy check `add` and `remove` share. Built by `createCore` as `core.enrollment`. | Read a device or word a refusal (the driver does); write a record (the registry does); run on a gateway. | | `DeviceProvisioner` | `src/core/device-provisioner.ts` | Creating a device: the component-removal gate, the registry record, the driver's `provision`, readiness for the lease handoff. | Decide whether to provision. | | `ManagedDeviceLifecycle` | `src/core/managed-device-lifecycle.ts` | Registry-owned device operations with a claim and a revalidation each: boot for a lease, boot back to warm, shutdown, destroy, dispose, recover a leased device. Every driver verb on an existing device goes through here. | Decide when to run them. | diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 7b1ef0e3d..48d54b260 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -166,6 +166,12 @@ export const deviceRecordSchema = z }) .superRefine((device, context) => checkDeviceType(device, context, true)); +/** + * What `status.get` returns to every agent, narrower than `deviceRecordSchema` (no `driverData`, + * no bookkeeping timestamps). `driverDeviceId` is here only for a physical device: its canonical + * ID is what an agent names to a command, so every role may see it; a virtual device's driver + * address is not actionable for a caller that does not hold it, so it is dropped. + */ export const statusDeviceSchema = z .object({ id: z.string(), diff --git a/src/core/create-core.ts b/src/core/create-core.ts index b0cb99323..f2a290401 100644 --- a/src/core/create-core.ts +++ b/src/core/create-core.ts @@ -116,8 +116,7 @@ export interface Core { /** * Read-only claim view for `Doctor`, which must not read a device a service is actively * operating on as a stalled transition. Exposed as a reader, not the claims themselves, so - * a consumer handed only this view cannot take or release a claim. (`claims` above is the - * full surface; leasing takes and releases through it.) + * a consumer handed only this view cannot take or release a claim. */ readonly claimReader: Pick; readonly cleanup: CleanupActionExecutor; diff --git a/src/core/physical-enrollment.test.ts b/src/core/physical-enrollment.test.ts index 7362b7956..41963af4e 100644 --- a/src/core/physical-enrollment.test.ts +++ b/src/core/physical-enrollment.test.ts @@ -422,6 +422,23 @@ describe("PhysicalEnrollment", () => { expect(live(harness)).toHaveLength(1); }); + it("answers DEVICE_BUSY busy to add and remove of a record in a state that is neither ready nor quarantined", async () => { + const harness = await build(); + await harness.core.enrollment.add("ios", UDID); + const real = harness.registry.snapshot; + vi.spyOn(harness.registry, "snapshot", "get").mockReturnValue({ + ...real, + devices: real.devices.map((device) => ({ ...device, state: "shutdown" }) as never), + }); + + for (const attempt of [ + () => harness.core.enrollment.add("ios", UDID), + () => harness.core.enrollment.remove(UDID), + ]) { + await expect(attempt()).rejects.toMatchObject({ name: "DeviceBusyError", reason: "busy" }); + } + }); + it("words a busy device as leased, reclaiming or busy with another operation", async () => { const harness = await build(); const { device } = await harness.core.enrollment.add("ios", UDID); diff --git a/src/core/physical-enrollment.ts b/src/core/physical-enrollment.ts index ae60d9dea..e596a9599 100644 --- a/src/core/physical-enrollment.ts +++ b/src/core/physical-enrollment.ts @@ -187,5 +187,8 @@ export class PhysicalEnrollment { throw new DeviceBusyError(device.driverDeviceId, "reclaiming"); } if (held !== undefined) throw new DeviceBusyError(device.driverDeviceId, "busy"); + if (device.state !== "ready" && device.state !== "quarantined") { + throw new DeviceBusyError(device.driverDeviceId, "busy"); + } } } diff --git a/src/core/quarantine-coordinator.test.ts b/src/core/quarantine-coordinator.test.ts index 5d5b7ea57..9c9fedd6c 100644 --- a/src/core/quarantine-coordinator.test.ts +++ b/src/core/quarantine-coordinator.test.ts @@ -832,7 +832,7 @@ describe("QuarantineCoordinator retry claim (ADR 0022 §2)", () => { expect(claimedAt).toEqual([false]); }); - it("has freed the claim before it emits and before it wakes availability when a retry gives up", async () => { + it("has freed the claim before it emits device.quarantine-abandoned and before it wakes availability when a retry gives up", async () => { const clock = new FakeClock(1_000); const driver = new FakeDriver({ clock, platform: "ios" }); driver.failOn("reclaim", 1, new Error("still stuck")); @@ -852,7 +852,7 @@ describe("QuarantineCoordinator retry claim (ADR 0022 §2)", () => { expect(claimedAt).toEqual([false, false]); }); - it("has freed the claim before it emits when a retry gives up and cannot destroy the device", async () => { + it("has freed the claim before it emits device.quarantine-stranded when a retry gives up and cannot destroy the device", async () => { const clock = new FakeClock(1_000); const driver = new FakeDriver({ clock, platform: "ios" }); driver.failOn("reclaim", 1, new Error("still stuck")); diff --git a/src/core/quarantine-coordinator.ts b/src/core/quarantine-coordinator.ts index 6c77a395e..cb29ff4c3 100644 --- a/src/core/quarantine-coordinator.ts +++ b/src/core/quarantine-coordinator.ts @@ -181,7 +181,7 @@ export class QuarantineCoordinator { ); if (device === undefined) return; - // Whoever claims first wins: a claim already held (an enrollment, a reclaim) makes the retry + // Whoever claims first wins: a claim already held (a recovery, a cleanup) makes the retry // wait one backoff, with its attempt count unchanged, and look again. const claim = this.options.claims.tryClaim(deviceId, "reclaim"); if (claim === undefined) { @@ -196,9 +196,10 @@ export class QuarantineCoordinator { } /** - * Every exit releases the claim right after the last write, before the facts and the wake-up - * that follow it: the planner never grants a claimed device, so a wake-up under the claim - * would pass over the device it was sent for (the order `LeaseReleaseCoordinator` uses). + * Every exit releases the claim right after the last write, before the coordinator's own facts + * and the wake-up that follow it: the planner never grants a claimed device, so a wake-up under + * the claim would pass over the device it was sent for. The registry's `device.deleted` fires + * inside that last write, still under the claim. */ async #retryClaimed(device: VirtualDevice, claim: DeviceOperationClaim): Promise { const attempts = (device.quarantineAttempts ?? 0) + 1; diff --git a/src/core/registry.test.ts b/src/core/registry.test.ts index 3838e3e02..55b0bf014 100644 --- a/src/core/registry.test.ts +++ b/src/core/registry.test.ts @@ -2774,11 +2774,16 @@ describe("Registry: device.deleted carries physical, and enrollment writes (ADR }); it("carries physical true and initiator operator from unenroll, after the commit", async () => { - const { deleted, modules, registry } = await load(); + const { bus, deleted, modules, registry } = await load(); const device = await registry.enrollPhysical(input); + const stateAtEmit: string[] = []; + bus.subscribe("device.deleted", () => { + stateAtEmit.push(registry.snapshot.devices[0]?.state ?? "none"); + }); await registry.unenrollPhysical(device.id); + expect(stateAtEmit).toEqual(["deleted"]); expect(deleted).toEqual([{ deviceId: device.id, initiator: "operator", physical: true }]); expect(modules).toEqual(["registry"]); expect(registry.snapshot.devices[0]).toMatchObject({ id: device.id, state: "deleted" }); @@ -2789,7 +2794,8 @@ describe("Registry: device.deleted carries physical, and enrollment writes (ADR const seen: unknown[] = []; const modules: string[] = []; bus.subscribe("device.enrolled", (envelope) => { - seen.push({ payload: envelope.payload, stored: registry.snapshot.devices.length }); + const stored = registry.snapshot.devices[0] as { enrolledApps: string[] } | undefined; + seen.push({ payload: envelope.payload, stored: stored?.enrolledApps.length ?? 0 }); modules.push(envelope.module); }); @@ -2812,7 +2818,7 @@ describe("Registry: device.deleted carries physical, and enrollment writes (ADR }, { payload: expect.objectContaining({ appCount: 2, model: "iPhone 16", reenrolled: true }), - stored: 1, + stored: 2, }, ]); expect(modules).toEqual(["registry", "registry"]); diff --git a/src/core/registry.ts b/src/core/registry.ts index 8b47942fe..6173cc1fb 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -641,9 +641,7 @@ export class Registry implements LeaseRequestStore { input: PhysicalEnrollmentInput, ): Promise { const { device, index } = this.#requireDeviceRecord(deviceId); - if (!device.physical || (device.state !== "ready" && device.state !== "quarantined")) { - throw new RegistryEventError(`Cannot re-enrol device: ${deviceId}`); - } + if (!device.physical) throw new RegistryEventError(`Cannot re-enrol device: ${deviceId}`); const { quarantineAttempts: _quarantineAttempts, quarantineNextRetryAt: _quarantineNextRetryAt, @@ -651,7 +649,17 @@ export class Registry implements LeaseRequestStore { deferredReclaimLeaseId: _deferred, ...kept } = device; - const settled = device.state === "quarantined" ? transition(kept, "ready") : kept; + let settled: PhysicalDevice = kept; + if (device.state !== "ready") { + try { + settled = transition(kept, "ready"); + } catch (error) { + if (error instanceof IllegalTransition) { + throw new RegistryEventError(`Cannot re-enrol device: ${deviceId}`); + } + throw error; + } + } const updated: PhysicalDevice = { ...settled, enrolledApps: [...input.apps], diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index 86ffb629d..9d673e5ee 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -123,8 +123,7 @@ export interface DispatcherOptions { readonly enrollment?: Pick; /** * Builds the scoped command behind `simlock simctl` / `simlock adb` (ADR 0001, decision 7). - * Optional for the same reason as `nuke`/`doctor`: the many tests that never issue a - * passthrough should not have to fabricate a resolver. + * Optional so the many tests that never issue a passthrough need not fabricate a resolver. */ readonly passthrough?: PassthroughResolver; /** @@ -846,8 +845,6 @@ export class Dispatcher { return this.options.enrollment; } - /** - */ #tokenCreate: Handler<"token.create"> = async (input) => { const { record, secret } = await this.#requireTokens().create(input.role, input.label); return { secret, token: record }; From 7117d7d53b63372a03b06b05712f582160e7e7e3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 11:30:43 +0200 Subject: [PATCH 18/22] test: a client on the previous protocol version is refused at hello (#437) Spec test added after review r2; 1 missing -> 0 missing --- e2e/physical-enrollment.test.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/e2e/physical-enrollment.test.ts b/e2e/physical-enrollment.test.ts index 35201e18f..531d94611 100644 --- a/e2e/physical-enrollment.test.ts +++ b/e2e/physical-enrollment.test.ts @@ -3,6 +3,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { PROTOCOL_VERSION_RANGE } from "../src/contract/index.js"; +import { NodeIpcTransport } from "../src/ports/index.js"; +import { SimlockWire, WireCallError } from "../src/simlock-client/wire.js"; import type { FakeDriverRefusalReason, ScriptedPhysicalDevice } from "./fake-driver/types.js"; import { freeLoopbackPort, waitFor, withDaemon, type TestEnv } from "./helpers/index.js"; @@ -289,6 +292,35 @@ describe("simlock device add", () => { }, ); + it("by a client whose hello range ends at the previous protocol version is refused with PROTOCOL_VERSION_UNSUPPORTED, so it never sends device.add to a daemon that would answer UNKNOWN_REQUEST", async () => { + const daemon = await httpDaemon({ ios: { physicalDevices: [IPHONE] } }); + const previous = PROTOCOL_VERSION_RANGE.max - 1; + const wire = new SimlockWire(await new NodeIpcTransport().connect(daemon.env.socketPath)); + + try { + const refused = await wire + .call("hello", { + clientVersion: "1.0.0", + protocolRange: { max: previous, min: previous }, + protocolVersion: previous, + }) + .then( + () => undefined, + (error: unknown) => error, + ); + + expect(refused).toBeInstanceOf(WireCallError); + expect((refused as WireCallError).code).toBe("PROTOCOL_VERSION_UNSUPPORTED"); + expect((refused as WireCallError).details).toMatchObject({ + client: { max: previous, min: previous }, + daemon: PROTOCOL_VERSION_RANGE, + }); + expect(await rows(daemon.env)).toEqual([]); + } finally { + await wire.close(); + } + }); + it("of an ID no scripted device lists answers DEVICE_NOT_ENROLLABLE not-connected", async () => { const daemon = await httpDaemon({ ios: { physicalDevices: [IPHONE] } }); From 974bcb78e811e5493018e764e308305acc4d4de2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 11:45:06 +0200 Subject: [PATCH 19/22] fix: tests prove the reason and the busy states their titles name; delete comments that restate or misname (#437) --- e2e/physical-enrollment.test.ts | 20 ++++++++++---------- src/contract/schemas.ts | 7 +++---- src/core/create-core.ts | 5 ----- src/core/physical-enrollment.test.ts | 2 +- src/core/quarantine-coordinator.ts | 5 +---- src/daemon/dispatcher.ts | 5 +---- 6 files changed, 16 insertions(+), 28 deletions(-) diff --git a/e2e/physical-enrollment.test.ts b/e2e/physical-enrollment.test.ts index 531d94611..86852d004 100644 --- a/e2e/physical-enrollment.test.ts +++ b/e2e/physical-enrollment.test.ts @@ -338,7 +338,8 @@ describe("simlock device add", () => { }); it("naming a Simlock simulator's driverDeviceId, or an emulator's driverDeviceId and, separately, its address, answers virtual-device and the driver log has no inspectPhysical call", async () => { - const env = await startDaemon({}); + const daemon = await httpDaemon({}); + const env = daemon.env; const simulator = await leaseGrant(env, "ios", "sim-agent"); const emulator = await leaseGrant(env, "android", "emu-agent"); await env.driverLog.clear(); @@ -352,16 +353,15 @@ describe("simlock device add", () => { const result = await env.cli(["device", "add", "--platform", platform, id]); expect(result.code, `${id}: ${result.stderr}`).toBe(12); expect(result.error?.code).toBe("DEVICE_NOT_ENROLLABLE"); + expect(result.error?.message).toContain("Simlock"); + const http = await postDevice(daemon, { id, platform }); + expect(http.status).toBe(422); + expect(http.body.error).toMatchObject({ + code: "DEVICE_NOT_ENROLLABLE", + id, + reason: "virtual-device", + }); } - const detail = await env.cli([ - "device", - "add", - "--platform", - "ios", - simulator.device.driverDeviceId, - "--json", - ]); - expect(detail.error?.message).toContain("Simlock"); expect(await inspectCalls(env)).toEqual([]); }); diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 48d54b260..f6d6f00b0 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -167,10 +167,9 @@ export const deviceRecordSchema = z .superRefine((device, context) => checkDeviceType(device, context, true)); /** - * What `status.get` returns to every agent, narrower than `deviceRecordSchema` (no `driverData`, - * no bookkeeping timestamps). `driverDeviceId` is here only for a physical device: its canonical - * ID is what an agent names to a command, so every role may see it; a virtual device's driver - * address is not actionable for a caller that does not hold it, so it is dropped. + * `driverDeviceId` is here only for a physical device: its canonical ID is how an operator names + * it to `device remove`, so every role may see it; a virtual device's driver address is not + * actionable for a caller that does not hold it, so it is dropped. */ export const statusDeviceSchema = z .object({ diff --git a/src/core/create-core.ts b/src/core/create-core.ts index f2a290401..b5f1b3990 100644 --- a/src/core/create-core.ts +++ b/src/core/create-core.ts @@ -113,11 +113,6 @@ export interface Core { readonly warmPoolReader: WarmPoolReader; readonly catalog: CatalogReader & PassthroughResolver; readonly claims: DeviceOperationClaims; - /** - * Read-only claim view for `Doctor`, which must not read a device a service is actively - * operating on as a stalled transition. Exposed as a reader, not the claims themselves, so - * a consumer handed only this view cannot take or release a claim. - */ readonly claimReader: Pick; readonly cleanup: CleanupActionExecutor; readonly decisions: SerializedDecision; diff --git a/src/core/physical-enrollment.test.ts b/src/core/physical-enrollment.test.ts index 41963af4e..f336e692e 100644 --- a/src/core/physical-enrollment.test.ts +++ b/src/core/physical-enrollment.test.ts @@ -422,7 +422,7 @@ describe("PhysicalEnrollment", () => { expect(live(harness)).toHaveLength(1); }); - it("answers DEVICE_BUSY busy to add and remove of a record in a state that is neither ready nor quarantined", async () => { + it("answers DEVICE_BUSY busy to add and remove of a record in a state that is none of ready, quarantined, leased or reclaiming", async () => { const harness = await build(); await harness.core.enrollment.add("ios", UDID); const real = harness.registry.snapshot; diff --git a/src/core/quarantine-coordinator.ts b/src/core/quarantine-coordinator.ts index cb29ff4c3..d8ce5a190 100644 --- a/src/core/quarantine-coordinator.ts +++ b/src/core/quarantine-coordinator.ts @@ -181,8 +181,6 @@ export class QuarantineCoordinator { ); if (device === undefined) return; - // Whoever claims first wins: a claim already held (a recovery, a cleanup) makes the retry - // wait one backoff, with its attempt count unchanged, and look again. const claim = this.options.claims.tryClaim(deviceId, "reclaim"); if (claim === undefined) { this.#arm(deviceId, this.options.clock.now() + this.options.config.retryBackoffMs); @@ -198,8 +196,7 @@ export class QuarantineCoordinator { /** * Every exit releases the claim right after the last write, before the coordinator's own facts * and the wake-up that follow it: the planner never grants a claimed device, so a wake-up under - * the claim would pass over the device it was sent for. The registry's `device.deleted` fires - * inside that last write, still under the claim. + * the claim would pass over the device it was sent for. */ async #retryClaimed(device: VirtualDevice, claim: DeviceOperationClaim): Promise { const attempts = (device.quarantineAttempts ?? 0) + 1; diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index 9d673e5ee..b657aad4e 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -121,10 +121,7 @@ export interface DispatcherOptions { readonly errorCode?: (error: unknown) => string | undefined; readonly nuke?: Nuke; readonly enrollment?: Pick; - /** - * Builds the scoped command behind `simlock simctl` / `simlock adb` (ADR 0001, decision 7). - * Optional so the many tests that never issue a passthrough need not fabricate a resolver. - */ + /** Optional so the many tests that never issue a passthrough need not fabricate a resolver. */ readonly passthrough?: PassthroughResolver; /** * Runs the command `device.exec` resolves (ADR 0005 §19a). Optional for the same reason as From 6dd7e2d54b1fc08863ca8ed9379035b8c4e15c24 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 11:50:30 +0200 Subject: [PATCH 20/22] docs: enrol refusal reasons and busy states are stated as the core decides them (#437) --- docs/CLI.md | 6 +++--- src/simlock-client/client.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 7ead7f749..ab79687a1 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1806,8 +1806,7 @@ add again gets a new record. `network-only`, `untrusted`, `developer-mode-off`, `os-too-old`, `unsupported-kind`, `locked`, `unreadable`, `virtual-device` (a simulator or emulator, including every one Simlock manages) or - `enrolled-on-other-platform`. The message is the driver's own, and says - what to do. + `enrolled-on-other-platform`. The message says why and what to do. - `DEVICE_BUSY` (exit 11): the device is `leased`, is `reclaiming`, or another operation holds it, such as another `device add` (`busy`). - `NO_DRIVER` (exit 12): this machine has no driver for the platform. @@ -1816,7 +1815,8 @@ add again gets a new record. `simlock list --devices` show as `driverDeviceId`. It asks for no confirmation and takes no `--yes`: it only makes Simlock forget the device, and sends nothing to it. An ID that is not enrolled is `UNKNOWN_DEVICE` -(exit 12); a leased or reclaiming device is `DEVICE_BUSY`. +(exit 12); a device that is leased, is reclaiming, or is held by another +operation (`busy`) is `DEVICE_BUSY`. Enrolling emits `device.enrolled` and removing emits `device.deleted` with `initiator` `operator`; see [EVENTS.md](EVENTS.md). The real iOS and Android diff --git a/src/simlock-client/client.ts b/src/simlock-client/client.ts index 265b47c3b..a1563c982 100644 --- a/src/simlock-client/client.ts +++ b/src/simlock-client/client.ts @@ -289,7 +289,7 @@ export interface SimlockAdminClient extends SimlockClient { /** * Enrols the physical device plugged into the host this daemon runs on, named by * any ID the platform's driver reads. A device it cannot enrol is `DEVICE_NOT_ENROLLABLE` with - * the driver's reason; a leased, reclaiming or busy one is `DEVICE_BUSY`. A device already + * a reason; a leased, reclaiming or busy one is `DEVICE_BUSY`. A device already * enrolled is enrolled again: its record is kept and its model, OS version, class and apps are * read anew (`reenrolled: true`). A gateway answers `UNSUPPORTED_IN_GATEWAY_MODE`. */ @@ -297,8 +297,8 @@ export interface SimlockAdminClient extends SimlockClient { /** * Forgets an enrolled physical device, named by its canonical ID. Nothing is sent - * to the device. `UNKNOWN_DEVICE` for an ID with no enrolled device, `DEVICE_BUSY` for a leased - * or reclaiming one. A gateway answers `UNSUPPORTED_IN_GATEWAY_MODE`. + * to the device. `UNKNOWN_DEVICE` for an ID with no enrolled device, `DEVICE_BUSY` for a leased, + * reclaiming or busy one (another operation holds it). A gateway answers `UNSUPPORTED_IN_GATEWAY_MODE`. */ removeDevice(input: DeviceRemoveInput): Promise; } From 37b9a6a815bc3317989f079b1b77054a597cf753 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 11:55:46 +0200 Subject: [PATCH 21/22] docs: DEVICE_NOT_ENROLLABLE message says why only; PhysicalEnrollment words its own refusals (#437) --- docs/CLI.md | 2 +- docs/internal/COMPONENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index ab79687a1..be3e853cc 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1806,7 +1806,7 @@ add again gets a new record. `network-only`, `untrusted`, `developer-mode-off`, `os-too-old`, `unsupported-kind`, `locked`, `unreadable`, `virtual-device` (a simulator or emulator, including every one Simlock manages) or - `enrolled-on-other-platform`. The message says why and what to do. + `enrolled-on-other-platform`. The message says why. - `DEVICE_BUSY` (exit 11): the device is `leased`, is `reclaiming`, or another operation holds it, such as another `device add` (`busy`). - `NO_DRIVER` (exit 12): this machine has no driver for the platform. diff --git a/docs/internal/COMPONENTS.md b/docs/internal/COMPONENTS.md index 9bebad238..b54f50c77 100644 --- a/docs/internal/COMPONENTS.md +++ b/docs/internal/COMPONENTS.md @@ -62,7 +62,7 @@ pool reads from acquisition. | `Registry` | `src/core/registry.ts` | Devices (virtual in `devices`, physical in `physicalDevices`, ADR 0022 §1), leases, lease requests and component records, written through one commit to `state.json`; the one virtual read, `snapshot.virtualDevices`; the device transition function's only caller; emits the post-commit device and lease facts. | Decide a transition: callers do, inside a decision section. | | `SerializedDecision` | `src/core/serialized-decision.ts` | Serialising short read-decide-commit sections. | Hold driver work or other long I/O. | | `DeviceOperationClaims` | `src/core/device-operation-claims.ts` | Exclusive per-device operation claims: boot, eviction, cleanup, nuke, recovery, reclaim, enrollment. | Any lifecycle, cleanup or leasing policy. | -| `PhysicalEnrollment` | `src/core/physical-enrollment.ts` | `device add` and `device remove` (ADR 0022 §2): refusing Simlock's own simulators and emulators, relaying the driver's read and its refusals, finding the one record a canonical ID has, taking the `enrollment` claim from the second read to the rewrite, and the busy check `add` and `remove` share. Built by `createCore` as `core.enrollment`. | Read a device or word a refusal (the driver does); write a record (the registry does); run on a gateway. | +| `PhysicalEnrollment` | `src/core/physical-enrollment.ts` | `device add` and `device remove` (ADR 0022 §2): refusing Simlock's own simulators and emulators and a device enrolled on the other platform (wording those refusals and the busy refusal itself), relaying the driver's read and its refusals, finding the one record a canonical ID has, taking the `enrollment` claim from the second read to the rewrite, and the busy check `add` and `remove` share. Built by `createCore` as `core.enrollment`. | Read a device or word the driver's refusals (the driver does); write a record (the registry does); run on a gateway. | | `DeviceProvisioner` | `src/core/device-provisioner.ts` | Creating a device: the component-removal gate, the registry record, the driver's `provision`, readiness for the lease handoff. | Decide whether to provision. | | `ManagedDeviceLifecycle` | `src/core/managed-device-lifecycle.ts` | Registry-owned device operations with a claim and a revalidation each: boot for a lease, boot back to warm, shutdown, destroy, dispose, recover a leased device. Every driver verb on an existing device goes through here. | Decide when to run them. | | `ReclaimCoordinator` | `src/core/reclaim-coordinator.ts` | After a release: the driver's reclaim (none for a physical device, which stays `reclaiming` until its own reclaim exists), committing the state it returns (`shutdown` on iOS, `ready` on Android) and waking the queue, handing a failed purge to quarantine, deleting a spent `fresh` device, recovering an interrupted reclaim at startup (a shutdown; for a device whose wipe a start put off, the full reclaim started in the background under a claim, and `settle` for a graceful stop). | Decide whether a device stays warm, boot anything, or read capacity or the queue. The warm pool (`src/core/warm-pool/`, ADR 0017) does. | From 7bea4bdbe5c35aa220627ebe528c8f1548cb0ae6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 11 Oct 2026 12:07:58 +0200 Subject: [PATCH 22/22] test: device.deleted payloads in startup-reconcile carry physical (#437) 2 failing -> 0 failing --- e2e/startup-reconcile.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/startup-reconcile.test.ts b/e2e/startup-reconcile.test.ts index 88cf8b04f..0d09f91f2 100644 --- a/e2e/startup-reconcile.test.ts +++ b/e2e/startup-reconcile.test.ts @@ -144,7 +144,7 @@ describe("startup ends every lease whose device is not running", () => { expect(ended[0]?.payload).toMatchObject({ leaseId: held.leaseId, reason: "device-lost" }); const deleted = (await env.events()).filter((entry) => entry.event === "device.deleted"); expect(deleted.map((entry) => entry.payload)).toEqual([ - { deviceId: held.deviceId, initiator: "doctor" }, + { deviceId: held.deviceId, initiator: "doctor", physical: false }, ]); expect(await reclaimCalls(env, held.driverDeviceId)).toEqual([]); }); @@ -266,7 +266,7 @@ describe("startup ends every lease whose device is not running", () => { expect(ended.map((entry) => entry.event)).toEqual(["lease.expired"]); const deleted = (await env.events()).filter((entry) => entry.event === "device.deleted"); expect(deleted.map((entry) => entry.payload)).toEqual([ - { deviceId: held.deviceId, initiator: "doctor" }, + { deviceId: held.deviceId, initiator: "doctor", physical: false }, ]); expect(await reclaimCalls(env, held.driverDeviceId)).toEqual([]); });