diff --git a/docs/CLI.md b/docs/CLI.md index 141ef2a9b..be3e853cc 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 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 | @@ -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 | @@ -112,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 @@ -908,10 +911,13 @@ 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 +**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. `driver.passthrough`, the operation behind the *local* form of `simlock simctl` / `simlock adb`, answers the same way for the same reason: a @@ -1193,6 +1199,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 +1768,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 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. + +`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 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 +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 @@ -2096,7 +2162,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/CLIENT.md b/docs/CLIENT.md index bb959dd1f..7550986a1 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: "dev_8f2a", 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/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/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..b92b6e723 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,59 @@ 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 + operation. 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": "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 + operation. +- `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 +1434,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`) | @@ -1394,9 +1451,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 97e741801..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 @@ -274,8 +275,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` @@ -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. @@ -966,7 +968,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 @@ -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/docs/internal/COMPONENTS.md b/docs/internal/COMPONENTS.md index f7f785383..b54f50c77 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, 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 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. | | `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. | @@ -106,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/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 | diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index e31f216b7..134197053 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; @@ -325,17 +326,17 @@ 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. 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/fake-driver.ts b/e2e/fake-driver/fake-driver.ts index 3fb86d10f..24e26ce01 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,24 @@ export class OutOfProcessFakeDriver implements Driver { }; } + 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/e2e/fake-driver/types.ts b/e2e/fake-driver/types.ts index 4483b98db..e7db2fc38 100644 --- a/e2e/fake-driver/types.ts +++ b/e2e/fake-driver/types.ts @@ -22,12 +22,11 @@ export type FakeDriverOperation = | "listCatalog" | "listComponents" | "removeComponent" - /** The root re-proof `doctor --purge-orphans` takes before its first destroy. */ - | "revalidateRoot"; + | "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 } @@ -35,6 +34,26 @@ export type FakeDriverErrorSpec = | { readonly type: "DriverCrashError"; readonly message?: string } | { readonly type: "generic"; readonly message?: string }; +export type FakeDriverRefusalReason = + | "not-connected" + | "network-only" + | "untrusted" + | "developer-mode-off" + | "os-too-old" + | "unsupported-kind" + | "locked" + | "unreadable" + | "virtual-device"; + +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 { @@ -53,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[]; @@ -101,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>; @@ -120,11 +129,7 @@ export interface FakeDriverPlatformScript { readonly reclaimStrategy?: "erase" | "snapshot" | "wipe"; readonly failures?: Partial>; readonly managedReality?: ScriptedManagedReality; - /** - * 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 physicalDevices?: readonly ScriptedPhysicalDevice[]; readonly leaseEnvironment?: Readonly>; /** * Overrides the `address` a `makeReady` boot returns (the script is re-read fresh on every @@ -133,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/e2e/physical-enrollment.test.ts b/e2e/physical-enrollment.test.ts new file mode 100644 index 000000000..86852d004 --- /dev/null +++ b/e2e/physical-enrollment.test.ts @@ -0,0 +1,673 @@ +import { readFile } from "node:fs/promises"; +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"; + +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("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] } }); + + 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(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 () => { + 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(); + + 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"); + 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", + }); + } + 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: enrolled.id }); + 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", + ); + 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/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([]); }); 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..c6a86695e 100644 --- a/src/bus/index.ts +++ b/src/bus/index.ts @@ -192,7 +192,21 @@ export interface EventMap { readonly deviceRoot: string; }; "device.shutdown": { readonly deviceId: string; readonly initiator: string }; - "device.deleted": { readonly deviceId: string; readonly initiator: string }; + "device.deleted": { + readonly deviceId: string; + readonly initiator: string; + readonly physical: boolean; + }; + "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; @@ -205,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 baf79faef..1ec941cb8 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1425,8 +1425,159 @@ 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.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"]); + + 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[]) { return fakeClient({ removeComponent: async (input) => { @@ -5196,6 +5347,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; @@ -5207,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 b7bbc672e..861a8b4fa 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 @@ -84,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; /** @@ -628,18 +618,10 @@ 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 - // 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), @@ -1945,21 +1927,113 @@ async function runWorkerAction( return 0; } +const DEVICE_USAGE = + "Usage: simlock device add --platform [--json]\n" + + " simlock device remove [--json]\n"; + +// `device remove` asks for no confirmation: 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 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(); + } +} + +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) return "help"; + const [id, ...extra] = values.positionals; + if (id === undefined || extra.length > 0) { + throw new UsageError(withHelpHint(`device ${command} takes exactly one `)); + } + 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", + ), + ); + } + 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.`; +} + +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" + " 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.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/errors.ts b/src/contract/errors.ts index 4961aa6e9..b38c711a8 100644 --- a/src/contract/errors.ts +++ b/src/contract/errors.ts @@ -1,44 +1,39 @@ -/** - * 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 = [ + "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". */ 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 }; @@ -81,53 +76,25 @@ 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 }; - /** 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. */ + DEVICE_NOT_ENROLLABLE: { + readonly platform: Platform; + readonly id: string; + readonly reason: DeviceNotEnrollableReason; + readonly canonicalId?: string; + }; + DEVICE_BUSY: { readonly id: string; readonly reason: "leased" | "reclaiming" | "busy" }; + UNKNOWN_DEVICE: { readonly id: string }; 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 @@ -176,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, @@ -204,6 +157,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, @@ -221,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; } @@ -308,6 +259,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: { @@ -327,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 d4b25543b..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" }, @@ -66,6 +65,8 @@ const ROLE_MATRIX: ReadonlyArray<{ }, // ADR 0010 §8: removal is destructive, so admin-only. { name: "component.remove", input: { platform: "ios", version: "26.4" }, role: "admin" }, + { name: "device.add", input: { id: "00008110-001A2B3C4D5E", platform: "ios" }, role: "admin" }, + { name: "device.remove", input: { id: "00008110-001A2B3C4D5E" }, role: "admin" }, ]; describe("operation role matrix", () => { @@ -83,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; @@ -133,6 +132,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 +1265,90 @@ 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); + }); + + 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/contract/operations.ts b/src/contract/operations.ts index a0108bd1a..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 - * `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", @@ -991,7 +964,49 @@ export const componentRemove = defineOperation({ }), }); -// ---- the full registry ---------------------------------------------------------------------- +const ENROLLED_DEVICE_ID_MAX_LENGTH = 200; + +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); +} + +// 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(), + }), +}); + +// 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() }), +}); export const OPERATIONS = { "catalog.get": catalogGet, @@ -1024,6 +1039,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..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 23, 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`. 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`). - */ -export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 23, max: 23 }; +/** 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.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(); }); diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 5b299de1a..f6d6f00b0 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -167,76 +167,25 @@ 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` (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), - * `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. + * `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({ 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(), - /** - * 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. - */ + driverDeviceId: z.string().optional(), 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 @@ -246,27 +195,15 @@ 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): typeof 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 - * `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 f4446112b..b5f1b3990 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"; @@ -108,25 +109,17 @@ 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; - /** - * 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.) - */ readonly claimReader: Pick; readonly cleanup: CleanupActionExecutor; readonly decisions: SerializedDecision; readonly deviceLifecycle: ManagedDeviceLifecycle; readonly doctor: Doctor; readonly drivers: DriverCatalog; + readonly enrollment: PhysicalEnrollment; readonly nuke: NukeService; readonly provisioner: DeviceProvisioner; readonly quarantine: QuarantineCoordinator; @@ -134,17 +127,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 @@ -238,6 +221,7 @@ export function createCore(options: CoreOptions): Core { registry, }); const quarantine = new QuarantineCoordinator({ + claims, clock: options.clock, config: options.config.warmPool.quarantine, decisions, @@ -258,6 +242,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 +345,7 @@ export function createCore(options: CoreOptions): Core { deviceLifecycle, doctor, drivers, + enrollment, nuke, provisioner, quarantine, @@ -373,7 +359,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 cece98da3..8850696e9 100644 --- a/src/core/device-operation-claims.ts +++ b/src/core/device-operation-claims.ts @@ -1,24 +1,15 @@ -/** - * 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" | "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 { 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/doctor.test.ts b/src/core/doctor.test.ts index 2cc33a42e..b8a7eb797 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, @@ -1314,6 +1315,88 @@ describe("Doctor", () => { }); }); + 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({ + 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); @@ -2494,6 +2577,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.test.ts b/src/core/driver.test.ts index 46fa88ca4..d225ea0a2 100644 --- a/src/core/driver.test.ts +++ b/src/core/driver.test.ts @@ -6,7 +6,9 @@ import { DiskSpaceGuard, type Driver, type DriverDevice, + type DriverRefusalReason, InsufficientDiskSpaceError, + type PhysicalInspection, sameReceipt, UnknownModelError, } from "./driver.js"; @@ -211,3 +213,39 @@ describe("the virtual-only driver and lifecycle calls (ADR 0022 §4, §5)", () = expect(virtualCallsCompile).toBeTypeOf("function"); }); }); + +describe("PhysicalInspection (ADR 0022 §5)", () => { + 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 otherPlatform; + } + + it("lets a driver refuse with every reason but enrolled-on-other-platform", () => { + 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/driver.ts b/src/core/driver.ts index b8979755d..6c82c6dc9 100644 --- a/src/core/driver.ts +++ b/src/core/driver.ts @@ -6,30 +6,16 @@ import type { DeviceMode, DeviceSpec, DeviceTransitionUpdate, + PhysicalClass, Platform, 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; } @@ -239,24 +225,40 @@ 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; } +export type DriverRefusalReason = + | "not-connected" + | "network-only" + | "untrusted" + | "developer-mode-off" + | "os-too-old" + | "unsupported-kind" + | "locked" + | "unreadable" + | "virtual-device"; + +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; - /** - * 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. @@ -369,6 +371,7 @@ export interface Driver { shutdown(device: VirtualDriverDevice): Promise; destroy(device: VirtualDriverDevice): Promise; listManaged(): Promise; + 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.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/fake-driver.ts b/src/core/fake-driver.ts index 4a9e072df..82c2c3dbc 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,7 @@ export type FakeDriverOperation = | "shutdown" | "destroy" | "listManaged" + | "inspectPhysical" | "listCatalog" | "listComponents" | "removeComponent" @@ -44,6 +54,15 @@ export type FakeDriverOperation = export type DriverEstimateOperation = "provision" | "boot" | "reclaim"; +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; @@ -52,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>; /** @@ -110,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, @@ -128,6 +136,7 @@ export interface FakeDriverOptions { readonly legacyDevices?: Readonly>; readonly reclaimResult?: "ready" | "shutdown"; readonly reclaimStrategy?: "erase" | "snapshot" | "wipe"; + readonly physicalDevices?: readonly FakePhysicalDevice[]; } export class FakeDriverUnknownDeviceError extends Error { @@ -142,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< @@ -187,6 +195,8 @@ 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[] | undefined; + readonly #scriptedPhysicalDevices: FakeDriverOptions["physicalDevices"]; constructor(options: FakeDriverOptions) { this.#availableOsVersions = new Set(options.availableOsVersions ?? ["latest"]); @@ -212,6 +222,7 @@ export class FakeDriver implements Driver { this.deviceRoot = options.deviceRoot ?? `/fake/${options.platform}`; this.#reclaimResult = options.reclaimResult ?? "ready"; this.#reclaimStrategy = options.reclaimStrategy ?? "wipe"; + this.#scriptedPhysicalDevices = options.physicalDevices; this.#install = options; this.#receipts = preinstalledReceipts(this.#availableOsVersions); } @@ -518,6 +529,21 @@ export class FakeDriver implements Driver { }; } + setPhysicalDevices(devices: readonly FakePhysicalDevice[]): void { + this.#physicalDevices = devices; + } + + async inspectPhysical(id: string): Promise { + await this.#beforeCall("inspectPhysical", 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" }; + } + if (device.refusal !== undefined) return { enrollable: false, ...device.refusal }; + return readPhysical(device, id); + } + async listCatalog(): Promise { await this.#beforeCall("listCatalog"); const runtimes = [...this.#availableOsVersions].sort(compareVersions); @@ -707,3 +733,14 @@ function compareVersions(left: string, right: string): number { return left.localeCompare(right); } + +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/index.ts b/src/core/index.ts index e2bdd960a..22702aa97 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -67,6 +67,7 @@ export { type ObservedDevice, type ObservedRunState, type PassthroughCommand, + type PhysicalInspection, type PrerequisiteCheck, PassthroughRefusedError, removeListedComponent, @@ -121,6 +122,12 @@ 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, + UnknownEnrolledDeviceError, +} from "./physical-enrollment.js"; export { DeviceProvisioner } from "./device-provisioner.js"; export { exactRequirement, diff --git a/src/core/physical-enrollment.test.ts b/src/core/physical-enrollment.test.ts new file mode 100644 index 000000000..f336e692e --- /dev/null +++ b/src/core/physical-enrollment.test.ts @@ -0,0 +1,590 @@ +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"); +} + +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); +} + +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("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(); + + 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("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")); + + await expect(harness.core.enrollment.add("ios", UDID)).rejects.toMatchObject({ + name: "PhysicalInspectionError", + }); + + expect(physicalRecords(harness)).toEqual([]); + expect(enrolledEvents(harness)).toEqual([]); + }); + + 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)); + 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.toThrow( + `${UDID} is enrolled as a ios device`, + ); + await expect(second.enrollment.add("android", UDID)).rejects.toMatchObject({ + canonicalId: UDID, + name: "DeviceNotEnrollableError", + reason: "enrolled-on-other-platform", + }); + expect(live(harness)).toHaveLength(1); + }); + + 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; + 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); + 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 busy with another operation: 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/physical-enrollment.ts b/src/core/physical-enrollment.ts new file mode 100644 index 000000000..e596a9599 --- /dev/null +++ b/src/core/physical-enrollment.ts @@ -0,0 +1,194 @@ +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, RegistrySnapshot } from "./registry.js"; +import type { SerializedDecision } from "./serialized-decision.js"; + +export type DeviceNotEnrollableReason = DriverRefusalReason | "enrolled-on-other-platform"; + +export class DeviceNotEnrollableError extends Error { + constructor( + readonly platform: Platform, + readonly id: string, + readonly reason: DeviceNotEnrollableReason, + message: string, + readonly canonicalId?: string, + ) { + super(message); + this.name = "DeviceNotEnrollableError"; + } +} + +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 busy with another operation: try again in a moment`, + ); + this.name = "DeviceBusyError"; + } +} + +export class UnknownEnrolledDeviceError extends Error { + constructor(readonly id: string) { + super(`${id} is not an enrolled device`); + this.name = "UnknownEnrolledDeviceError"; + } +} + +class PhysicalInspectionError extends Error { + constructor(message: string) { + super(message); + this.name = "PhysicalInspectionError"; + } +} + +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: PhysicalEnrollmentRegistry; +} + +export interface PhysicalEnrollmentRegistry { + readonly snapshot: Pick; + enrollPhysical(input: PhysicalEnrollmentInput): Promise; + reenrollPhysical(deviceId: string, input: PhysicalEnrollmentInput): Promise; + unenrollPhysical(deviceId: string): Promise; +} + +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(); + } + } + + // 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); + if (existing === undefined) throw new UnknownEnrolledDeviceError(id); + this.#requireFree(existing); + return this.options.registry.unenrollPhysical(existing.id); + }); + } + + #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(canonicalId ?? 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, + }; + } + + #enrolled(canonicalId: string): PhysicalDevice | undefined { + return this.options.registry.snapshot.devices.find( + (device): device is PhysicalDevice => + device.physical && device.state !== "deleted" && device.driverDeviceId === canonicalId, + ); + } + + #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"); + 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 940f48cfc..9c9fedd6c 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, @@ -732,3 +739,146 @@ 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("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(); + + 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 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")); + 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]); + }); + + 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")); + 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 a26ad24f4..d8ce5a190 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"; @@ -25,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; @@ -40,6 +39,7 @@ export interface QuarantineRetryConfig { } export interface QuarantineCoordinatorOptions { + readonly claims: Pick; readonly clock: Clock; readonly config: QuarantineRetryConfig; readonly decisions: Pick; @@ -50,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; @@ -199,10 +181,28 @@ export class QuarantineCoordinator { ); if (device === undefined) return; + 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 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. + */ + 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 +210,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 +231,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,9 +261,13 @@ 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); @@ -266,7 +277,11 @@ export class QuarantineCoordinator { 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 +295,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 +306,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..6f279398b 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; } @@ -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/core/registry.test.ts b/src/core/registry.test.ts index 9e46fa2bd..55b0bf014 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,231 @@ 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[] = []; + 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) { + 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, modules, 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 }, + ]); + expect(modules).toEqual(["registry", "registry", "registry", "registry"]); + }); + + it("carries physical true and initiator operator from unenroll, after the commit", async () => { + 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" }); + }); + + 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[] = []; + const modules: string[] = []; + bus.subscribe("device.enrolled", (envelope) => { + const stored = registry.snapshot.devices[0] as { enrolledApps: string[] } | undefined; + seen.push({ payload: envelope.payload, stored: stored?.enrolledApps.length ?? 0 }); + modules.push(envelope.module); + }); + + 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: 2, + }, + ]); + 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 () => { + 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( + `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}`, + ); + 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/core/registry.ts b/src/core/registry.ts index 26d7a9a90..6173cc1fb 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, @@ -105,6 +106,15 @@ export interface RegisterDeviceInput { readonly provisionDuration: number; } +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; @@ -114,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; /** @@ -138,7 +147,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) { @@ -286,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); @@ -302,18 +314,12 @@ export class Registry implements LeaseRequestStore { await this.#commit(devices, this.#leases); if (event !== undefined) { - this.#emitDeviceEvent(event); + this.#emitDeviceEvent(event, updated); } 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") { @@ -435,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); @@ -455,11 +456,14 @@ 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); } - /** 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 @@ -474,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) { @@ -535,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)) { @@ -545,7 +547,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 +581,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 +612,119 @@ export class Registry implements LeaseRequestStore { return deleted; } + 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; + } + + async reenrollPhysical( + deviceId: string, + input: PhysicalEnrollmentInput, + ): Promise { + const { device, index } = this.#requireDeviceRecord(deviceId); + if (!device.physical) throw new RegistryEventError(`Cannot re-enrol device: ${deviceId}`); + const { + quarantineAttempts: _quarantineAttempts, + quarantineNextRetryAt: _quarantineNextRetryAt, + quarantinedAt: _quarantinedAt, + deferredReclaimLeaseId: _deferred, + ...kept + } = device; + 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], + readyAt: this.options.clock.now(), + spec: { + class: input.class, + model: input.model, + osVersion: input.osVersion, + physical: true, + platform: device.spec.platform, + }, + }; + const devices = [...this.#devices]; + devices[index] = updated; + await this.#commit(devices, this.#leases); + this.#emitEnrolled(updated, true); + return cloneDevice(updated) as PhysicalDevice; + } + + async unenrollPhysical(deviceId: string): Promise { + const { device, index } = this.#requireDeviceRecord(deviceId); + if (!device.physical) throw new RegistryEventError(`Cannot unenrol device: ${deviceId}`); + const { + quarantineAttempts: _quarantineAttempts, + quarantineNextRetryAt: _quarantineNextRetryAt, + quarantinedAt: _quarantinedAt, + ...kept + } = device; + 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); + 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 +1028,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 +1040,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-protocol/index.test.ts b/src/daemon-protocol/index.test.ts index 5612c0527..5752af9e3 100644 --- a/src/daemon-protocol/index.test.ts +++ b/src/daemon-protocol/index.test.ts @@ -9,17 +9,7 @@ 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. - expect(DAEMON_PROTOCOL_VERSION).toBe(23); + expect(DAEMON_PROTOCOL_VERSION).toBe(24); expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n'); }); diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 2da44d4eb..9c486774e 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -158,34 +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; - /** 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 includeEnrollment?: boolean; 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 @@ -321,6 +303,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 +3283,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: registry.snapshot.devices[0]?.id }); + 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 }); @@ -3308,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 60b1a7afd..b657aad4e 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, @@ -108,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; @@ -118,11 +120,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; - /** - * 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. - */ + readonly enrollment?: Pick; + /** 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 @@ -276,6 +275,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"), @@ -284,20 +285,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, @@ -813,11 +808,40 @@ 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. - */ + #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.id }; + } 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; + } + #tokenCreate: Handler<"token.create"> = async (input) => { const { record, secret } = await this.#requireTokens().create(input.role, input.label); return { secret, token: record }; @@ -900,7 +924,27 @@ function killQuietly(handle: StreamingProcessHandle, signal: NodeJS.Signals): vo } } -/** ADR 0012 §2: an operation that acts on a gateway's workers, asked of a worker. */ +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; +} + function unsupportedOnWorker( operation: OperationName, message = `${operation} acts on a gateway's workers, and this daemon is not a gateway`, 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 { [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,19 +195,13 @@ 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, }); }, ); - // 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", @@ -655,11 +647,33 @@ 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: "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({ @@ -2895,24 +2909,13 @@ 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; + readonly includeEnrollment?: boolean; } = {}, ) { const directory = @@ -2978,6 +2981,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/daemon/server.ts b/src/daemon/server.ts index fa338e94a..e8417e9cc 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -144,33 +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; } @@ -285,14 +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. function buildDispatcher( options: DaemonServerCommonOptions & DaemonServerEngineOptions, hooks: { @@ -310,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, @@ -320,6 +300,7 @@ function buildDispatcher( instanceId: options.instanceId, leases: options.leases, ...(options.logger === undefined ? {} : { logger: options.logger }), + ...(options.enrollment === undefined ? {} : { enrollment: options.enrollment }), ...(options.nuke === undefined ? {} : { nuke: options.nuke }), ...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }), ...(options.processRunner === undefined ? {} : { processRunner: options.processRunner }), @@ -1187,6 +1168,10 @@ export class DaemonServer { frame.payload, this.#session(connection), ); + case "device.add": + return this.#dispatcher.dispatch("device.add", frame.payload, this.#session(connection)); + case "device.remove": + return this.#dispatcher.dispatch("device.remove", frame.payload, this.#session(connection)); // "daemon.stop" is deliberately absent from this switch: `#dispatchLine` intercepts it // itself, ahead of the protocol-mismatch and `#stopping` gates (ADR §6's frozen exception, // scoped to protocol version only -- still gated on a completed handshake and the `admin` @@ -1196,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/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/android/index.ts b/src/drivers/android/index.ts index 0d93df669..1ebef0ab5 100644 --- a/src/drivers/android/index.ts +++ b/src/drivers/android/index.ts @@ -23,6 +23,7 @@ import { DriverCrashError, type DriverEstimate, type DriverReality, + type PhysicalInspection, type DriverToolVersion, type InstalledComponent, LicenseNotAcceptedError, @@ -1014,6 +1015,14 @@ export class AndroidDriver implements Driver { }); } + async inspectPhysical(_id: string): 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.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/drivers/ios/index.ts b/src/drivers/ios/index.ts index 96b279d94..29af8d9e7 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,14 @@ export class IosSimctlDriver implements Driver { await this.#legacySimctl(["delete", udid], COMMAND_TIMEOUT_MS); } + 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.test.ts b/src/gateway/dispatcher.test.ts index 2b3e86cdf..2c7199a2d 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -856,14 +856,41 @@ 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(); - // 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 06a5e161e..1191fa956 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", ), + // 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, @@ -214,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/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/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 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/http/app.ts b/src/http/app.ts index 7cec56ae6..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,7 +149,8 @@ const componentInstallBodySchema = z }) .strict(); -/** A route's JSON body, validated against its schema; a body that fails is `400 BAD_REQUEST`. */ +const deviceAddBodySchema = z.object({ id: z.string(), platform: z.string() }).strict(); + function jsonBody(schema: Schema) { return zValidator("json", schema, (result, c) => { if (!result.success) { @@ -284,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") { @@ -303,10 +299,27 @@ export function createHttpApp(deps: HttpGatewayDeps): Hono & HttpAppDisposa return c.json(result); }); + 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"); - // 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/leasing/create-leasing.test.ts b/src/leasing/create-leasing.test.ts index f9cbb0960..a9ffa8ac4 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"]); @@ -4117,3 +4121,59 @@ 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(); + + 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 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")); + 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.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 }, + ]); + }); +}); 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/src/simlock-client/client.test.ts b/src/simlock-client/client.test.ts index c25e23e7d..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", @@ -71,6 +66,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 +221,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/src/simlock-client/client.ts b/src/simlock-client/client.ts index 3001d961b..a1563c982 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,12 +285,24 @@ export interface SimlockAdminClient extends SimlockClient { * counts as details) or `COMPONENT_BUSY`. */ removeComponent(input: ComponentRemoveInput): Promise; + + /** + * 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 + * 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`. + */ + addDevice(input: DeviceAddInput): Promise; + + /** + * 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, + * reclaiming or busy one (another operation holds it). 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, @@ -407,6 +427,8 @@ function buildDegradedClient( installComponent: () => rejected(), installComponentOnWorkers: () => rejected(), removeComponent: () => rejected(), + addDevice: () => rejected(), + removeDevice: () => rejected(), }; return client; } @@ -421,14 +443,6 @@ async function resolveConnection(options: ConnectOptions): Promise void>(); @@ -653,18 +667,14 @@ class SimlockClientImpl { return this.#call("component.remove", input); } - // ---- abort (ADR §10) ------------------------------------------------------------------------ + addDevice(input: DeviceAddInput): Promise { + return this.#call("device.add", input); + } + + removeDevice(input: DeviceRemoveInput): Promise { + return this.#call("device.remove", input); + } - /** - * 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 d290c3e63..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">; @@ -89,8 +87,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">; - -// ---- pushes --------------------------------------------------------------------------------- +export type DeviceAddInput = OpInput<"device.add">; +export type DeviceAddOutput = OpOutput<"device.add">; +export type DeviceRemoveInput = OpInput<"device.remove">; +export type DeviceRemoveOutput = OpOutput<"device.remove">; export type LeaseProgress = z.infer; export type EventPush = z.infer<(typeof PUSH_SCHEMAS)["event"]>; diff --git a/ui/src/views/worker-detail.test.tsx b/ui/src/views/worker-detail.test.tsx index 375308e17..9869cf693 100644 --- a/ui/src/views/worker-detail.test.tsx +++ b/ui/src/views/worker-detail.test.tsx @@ -94,6 +94,49 @@ 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 and for a simulator with no 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", + }, + { + 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", + 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 Virtual iPhone 16 26.5 full —"); + 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; 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 ( { 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, @@ -99,7 +98,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 +118,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", ); @@ -133,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");