Skip to content
Merged
24 changes: 19 additions & 5 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ command starts it again) to bring the platform up.
| 2 | `UNKNOWN_REQUEST` | the daemon has no such operation — usually a client newer than the daemon |
| 2 | `PASSTHROUGH_REFUSED` | a `simctl`/`adb` verb simlock refuses, a caller-supplied `--set`/`-P`, or a bare `adb shell` where there is no terminal to give it |
| 2 | `UNKNOWN_PASSTHROUGH_TOOL` | a passthrough tool simlock does not wrap |
| 2 | `IDEMPOTENCY_CONFLICT` | a lease request reused an idempotency key its requester already sent for a different device; use a new key |
| 2 | `IDEMPOTENCY_CONFLICT` | a lease request reused an idempotency key its requester already sent for a different device or `--lease-id`; use a new key |
| 10 | `QUEUE_TIMEOUT` | timed out waiting for a device (`--timeout` elapsed) |
| 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` |
Expand All @@ -84,6 +84,7 @@ command starts it again) to bring the platform up.
| 12 | `COMPONENT_NOT_OWNED` | `component remove` of a component Simlock did not install, or one that changed on disk since |
| 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 |
| 14 | — | `lease` without `--detach` only: the daemon ended the lease without the holder asking (TTL expiry, operator `release`, or an unrecoverable device) |
| 15 | — | `component install --worker`/`--all-workers` on a gateway only: at least one worker did not end `installed` or `already-installed` (it refused, failed, was skipped, or its result is unknown) |

Expand Down Expand Up @@ -176,7 +177,7 @@ a timer and releasing it when it exits.
```
simlock lease --platform <ios|android> [--device <model> | --class <class>]
[--os <version|range>] [--mode <slim|full>] [--image-tag <tag>] [--agent-id <id>]
[--timeout <duration>]
[--timeout <duration>] [--lease-id <id>]
[--no-wait] [--detach] [--ttl <duration>] [--allow-download]
[--export-env] [--bind-pid <pid>]
```
Expand Down Expand Up @@ -209,6 +210,15 @@ granted.
[Agent identity](#agent-identity). Defaults to `SIMLOCK_AGENT_ID`, then the
agent tool's session id, then a pid-derived value.
- `--timeout` — max time to wait in the queue (exit 10 on expiry).
- `--lease-id <id>` — the ID the granted lease gets, in place of one Simlock
generates, for a caller that already has its own ID for the lease. 1 to 64
ASCII letters, digits, `-` and `_`, starting with a letter or digit, and
case-sensitive; anything else is a `BAD_REQUEST` (exit 2). The ID is yours
to keep unique for all time: use it for one lease and do not send it again
once that lease has ended. An ID an active lease or a waiting request
already holds is `LEASE_ID_TAKEN` (exit 13); a requester that already holds
a lease gets `REQUESTER_ALREADY_LEASED` first. `renew`, `release` and
`list` then name the lease by this ID, through a gateway too.
- `--no-wait` — fail immediately with exit 11 instead of queueing.
- `--allow-download` — permit downloading a missing runtime / system image
(multi-GB; never implicit). Without it, a missing runtime is exit 12.
Expand Down Expand Up @@ -789,9 +799,13 @@ The grant carries one additional block so you can see where it landed:
{"lease":{"id":"3f81a2c4.lse_9f2c","worker":{"id":"3f81a2c4","label":"mac-studio-2"}}}
```

The lease id names its worker (that is how renew, release, and reads route
with no gateway-side state to lose), but it is **opaque** — do not parse it.
`worker.label` is display-only.
A lease Simlock named has an id that names its worker, but it is **opaque**
— do not parse it. A lease you named with `--lease-id` keeps exactly that ID
through a gateway, with no worker in front of it. The gateway finds the
worker of every lease, either kind, from a table it keeps in memory and
rebuilds from its workers after a restart, so a renew or release in the
moment after a restart can answer `UNKNOWN_LEASE` until the worker has
reported. `worker.label` is display-only.

**`lease renew`, `release`, and lease reads are forwarded** to the worker
that owns the lease, and the `ttlDeadline` you see is that worker's own.
Expand Down
24 changes: 22 additions & 2 deletions docs/CLIENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,25 @@ installer printed one. A request that joins a download already running hears
that download's latest progress at once. A request that needs no download
never hears this stage.

**Choosing the lease ID.** Pass `leaseId` when you already have your own ID
for the lease and want Simlock to use it, so there is nothing to map:

```ts
const grant = await client.requestLease({ platform: "ios", leaseId: "ad-7f3a" });
grant.lease.id; // "ad-7f3a"
grant.lease.idChosenByRequester; // true
```

The ID is 1 to 64 ASCII letters, digits, `-` and `_`, starts with a letter or
digit, and is case-sensitive; anything else is a `BAD_REQUEST`. `renewLease`,
`releaseLease` and the lease lists then name the lease by that ID, against a
gateway too, where it comes back with no worker in front of it. You keep the
ID unique for all time: it names one lease, and you do not send it again once
that lease has ended. Simlock refuses one an active lease or a waiting request
holds with `LEASE_ID_TAKEN` (`details.leaseId`), after the
`REQUESTER_ALREADY_LEASED` check, and keeps no record of IDs already used.
Without `leaseId` a request gets an ID from Simlock, as before.

**Keeping the lease alive is yours to do.** Every lease is TTL-bound: it expires at
`grant.lease.ttlDeadline` unless a `renewLease` call lands first, and the
daemon does nothing on its own to keep it. `requestLease` takes an optional
Expand Down Expand Up @@ -160,7 +179,8 @@ the request is still waiting you join that wait, and once it has a result
you get that result. Either way it never grants you a second lease. A result
is never worked out again: a request that failed stays failed under its key,
so use a new key to try again. Keys last for `lease.requestRetentionMs` after
the request finishes. The same key with a different device is
the request finishes. The same key with a different device, or a different
`leaseId` (sent on one call and not the other counts as different), is
`IDEMPOTENCY_CONFLICT`. Keys belong to a requester id, and a repeat must come
from the same connection principal that sent the request: the same key and
requester id from a different principal is `FORBIDDEN`. A request still waiting when the daemon restarts
Expand Down Expand Up @@ -737,7 +757,7 @@ difference, and neither does code written against it.
mode (`"slim" | "full"`). Everything else you might reach for is a leaky
inference rather than an answer: a lease from a gateway carries an additive
`worker: { id, label }` block, but so might a future single-machine daemon's;
a lease id from a gateway names its worker, but ids are opaque and parsing
a lease id from a gateway can name its worker, but ids are opaque and parsing
one is a bug waiting to happen.

Two behaviours worth knowing when the daemon on the other end is a gateway,
Expand Down
4 changes: 2 additions & 2 deletions docs/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ through `simlock events` and `simlock events --follow`.
| `lease.renewed` | lease id, new deadline | a `lease.renew` succeeded — whether it came from `simlock lease renew`, `POST /v1/leases/{id}/renew`, or the renew timer a running `simlock lease` / MCP session keeps over its own lease. There is one renew path and this is it | LeaseLifecycle | implemented |
| `lease.released` | lease id, device id, reason (explicit/killed/device-lost), owner id | an explicit `lease.release` (which is what a `simlock lease` holder does on its way out), (killed) an operator `release --all` or `nuke`, or (device-lost) a leased device could not be recovered after it stopped running outside simlock, or a daemon start found its device not running, and the device of that lease was wiped and returned to the pool, left waiting in `reclaiming` on a platform the daemon could not list, or marked missing. Closing a connection is not a release and never emits this | LeaseLifecycle | implemented |
| `lease.expired` | lease id, device id, owner id | the lease's deadline passed with no `lease.renew` behind it — the grant-time TTL, or the TTL of the last renew, simply ran out. This is the one way a lease ends without somebody asking, and the only bound on a holder that was killed outright | LeaseLifecycle | implemented |
| `lease.rejected` | request id, requester, request spec (as on `lease.requested`: a request that named a class or nothing has no model, and one that named an OS range carries it as typed), reason (timeout/no-wait/unresolvable-spec/no-worker/already-leased/boot-timeout/killed/cancelled/daemon-restarted) | a request ended without a grant. A request refused before it was stored (`killed`, `already-leased`) emits no `lease.requested`, and its `lease.rejected` carries the id it would have been stored under; on a gateway, `no-worker` is a request no worker that takes requests can serve, `NO_CAPACITY` at once, and `unresolvable-spec` is one no known worker has the platform, model, or runtime for, and such a request emits no `lease.queued`. A request a worker refused as unable to serve (`RUNTIME_MISSING`, `UNKNOWN_MODEL`, `NO_DRIVER`) while another worker was busy waits in the gateway queue, unless it is a `noWait` request, which is rejected at once with reason `no-wait` and emits no `lease.queued`; a request that did queue and then finds no worker left emits `unresolvable-spec` after its `lease.queued`, and one that never queued gets only the refusing worker's own `lease.rejected`; `daemon-restarted` is a request still waiting when the daemon stopped, settled as failed when it starts again. The reason list can grow: a consumer must tolerate a reason it does not know; `cancelled` is an explicit single-request cancel (backing `DELETE /v1/lease-requests/{id}`) of a still-queued waiter — one with device work already in flight is reported `not-cancellable` instead, the same envelope the queue timeout already uses | LeaseAcquisitionCoordinator / WaitQueue / LeaseStartup (worker) / FleetLeaseCoordinator (gateway) | implemented |
| `lease.rejected` | request id, requester, request spec (as on `lease.requested`: a request that named a class or nothing has no model, and one that named an OS range carries it as typed), reason (timeout/no-wait/unresolvable-spec/no-worker/already-leased/lease-id-taken/boot-timeout/killed/cancelled/daemon-restarted) | a request ended without a grant. A request refused before it was stored (`killed`, `already-leased`, `lease-id-taken`) emits no `lease.requested`, and its `lease.rejected` carries the id it would have been stored under (on a gateway, `lease-id-taken` can also end a request that was stored, when a worker's grant carries an ID the gateway already routes elsewhere: that one has its `lease.requested`); on a gateway, `no-worker` is a request no worker that takes requests can serve, `NO_CAPACITY` at once, and `unresolvable-spec` is one no known worker has the platform, model, or runtime for, and such a request emits no `lease.queued`. A request a worker refused as unable to serve (`RUNTIME_MISSING`, `UNKNOWN_MODEL`, `NO_DRIVER`) while another worker was busy waits in the gateway queue, unless it is a `noWait` request, which is rejected at once with reason `no-wait` and emits no `lease.queued`; a request that did queue and then finds no worker left emits `unresolvable-spec` after its `lease.queued`, and one that never queued gets only the refusing worker's own `lease.rejected`; `daemon-restarted` is a request still waiting when the daemon stopped, settled as failed when it starts again. The reason list can grow: a consumer must tolerate a reason it does not know; `cancelled` is an explicit single-request cancel (backing `DELETE /v1/lease-requests/{id}`) of a still-queued waiter — one with device work already in flight is reported `not-cancellable` instead, the same envelope the queue timeout already uses | LeaseAcquisitionCoordinator / WaitQueue / LeaseStartup (worker) / FleetLeaseCoordinator (gateway) | implemented |

On a **gateway**, the first three of these are its own fleet queue's facts,
emitted by `FleetLeaseCoordinator` and never by the worker whose device is
eventually granted — `lease.granted`/`renewed`/`released`/`expired` for a fleet
lease arrive already relayed from the owning worker (see "Fleet (gateway
mode)" below), so a gateway never emits those four itself. `already-leased`
on a gateway is the fleet-wide one-lease-per-requester check, answered from
the gateway's own lease index before any worker is ever contacted.
the gateway's own lease index before any worker is ever contacted. `lease-id-taken` is a `leaseId` an active lease or a waiting request already holds; on a gateway it is the gateway's own leases and requests, checked the same way, and a worker's own refusal of it is that worker's `lease.rejected`.

## Capacity and queue

Expand Down
Loading
Loading