Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
eabd143
test: simlock device add / remove: an admin enrolls and unenrolls phy…
V3RON Oct 11, 2026
3399a90
feat(core): device add and device remove enrol and unenrol physical d…
V3RON Oct 11, 2026
b0430fd
docs: device add and device remove, both EVENTS.md, the status driver…
V3RON Oct 11, 2026
fb19bdb
test: unit tests for enrollment, quarantine claim, registry, contract…
V3RON Oct 11, 2026
a493159
chore: protocol 24 across tests and docs; device.deleted carries phys…
V3RON Oct 11, 2026
66e2d57
test: a physical device's status entry keeps its canonical ID (#437)
V3RON Oct 11, 2026
702260f
fix: fallow findings for enrollment: ports as interfaces, smaller CLI…
V3RON Oct 11, 2026
64a927f
fix: fallow findings for enrollment: no class-indexed type, smaller f…
V3RON Oct 11, 2026
b769a59
test: the console's devices table has a Type column; drop stale suppr…
V3RON Oct 11, 2026
964d292
test: the console spec's status type carries physical (#437)
V3RON Oct 11, 2026
116a2f9
fix: read an enrolled device by its canonical ID under the claim; del…
V3RON Oct 11, 2026
a67f123
test: kill surviving mutants of enrollment: messages, claims, parsing…
V3RON Oct 11, 2026
daf7892
test: the other-platform refusal names the platform (#437)
V3RON Oct 11, 2026
9300c64
fix: device remove answers the registry ID; unenrol and re-enrol go t…
V3RON Oct 11, 2026
53a3d54
chore: delete comments that say what, point elsewhere or tell history…
V3RON Oct 11, 2026
2805f3d
test: a stopped simulator is refused unenrol; the guard is what refus…
V3RON Oct 11, 2026
fff6d5d
fix: tests prove emit-after-commit; one place decides re-enrol busy; …
V3RON Oct 11, 2026
7117d7d
test: a client on the previous protocol version is refused at hello (…
V3RON Oct 11, 2026
974bcb7
fix: tests prove the reason and the busy states their titles name; de…
V3RON Oct 11, 2026
6dd7e2d
docs: enrol refusal reasons and busy states are stated as the core de…
V3RON Oct 11, 2026
37b9a6a
docs: DEVICE_NOT_ENROLLABLE message says why only; PhysicalEnrollment…
V3RON Oct 11, 2026
7bea4bd
test: device.deleted payloads in startup-reconcile carry physical (#437)
V3RON Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 74 additions & 8 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -908,10 +911,13 @@ install <apk>` 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <ios|android> <id>` / `device remove <id>`

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 <name>]`

Run the cleanup reconciliation immediately. `--dry-run` prints the actions
Expand Down Expand Up @@ -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:

Expand Down
38 changes: 37 additions & 1 deletion docs/CLIENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/CONSOLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading