Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
31a02c2
feat(contract,core): config.mode, worker views and the gateway error …
claude Sep 6, 2026
d63c6df
feat(ports,daemon): the worker uplink, and a daemon that can serve ei…
claude Sep 6, 2026
e282b7f
feat(gateway): worker views, worker.* operations and aggregated status
claude Sep 6, 2026
f73d1b8
feat(http,cli,daemon): /v1/workers, simlock worker, and starting in g…
claude Sep 6, 2026
0d314a0
test(e2e): two workers join a gateway, and fix the frame the uplink w…
claude Sep 6, 2026
0bd50e7
docs: gateway and worker modes, the uplink, and the worker surface
claude Sep 6, 2026
78a4422
fix(gateway): one mode field on status.get, and device.exec on the fl…
claude Sep 6, 2026
b3fcd99
fix(gateway): worker.rejected is only ever about the door
claude Sep 6, 2026
31d05e2
fix(contract): leave the protocol bump to #116
claude Sep 6, 2026
90ee382
refactor: clear the fallow audit on this branch's files
claude Sep 6, 2026
d5aab3c
docs: mode is a field of status.get's daemon block
claude Sep 6, 2026
4838852
fix(gateway): bound worker RPCs and stop a stale link from disconnect…
claude Sep 7, 2026
fa2f7f5
fix(gateway): clear a removed worker's drain flag and scope the reten…
claude Sep 7, 2026
f184182
fix(gateway): report what a refused uplink claimed to be (M3)
claude Sep 7, 2026
7c4f783
test(gateway): recurse into subdirectories and catch dynamic imports …
claude Sep 7, 2026
e7024c7
docs(gateway): replace the stale "gateway is inert" paragraph (D4)
claude Sep 7, 2026
bd66866
test(core): a gateway defaults http.enabled rather than failing witho…
claude Sep 7, 2026
4d5f681
fix(gateway): retention must not clear a drain flag, and fail closed …
claude Sep 7, 2026
79277a6
fix(gateway): bound the hello round trip inside connect() (C3)
claude Sep 7, 2026
fde0365
fix(gateway): resolve import specifiers before matching the boundary …
claude Sep 7, 2026
3b6137d
fix(gateway): close a link after repeated refresh timeouts (P1)
claude Sep 7, 2026
39e85e5
fix(gateway): namespace lease.list's ownership comparison (P2)
claude Sep 7, 2026
f32ddc7
fix(uplink): truncate and sanitize claimed worker id/label before aut…
claude Sep 7, 2026
69fd9f1
fix(daemon): don't reset uplink backoff until a link proves stable (H1)
claude Sep 7, 2026
b6a6a49
fix(gateway): re-check link identity before writing a refresh (H2)
claude Sep 7, 2026
56ee730
fix(gateway): distinguish a timed-out event subscription from a refus…
claude Sep 7, 2026
28a6c72
feat(gateway): declare fleet-ports.ts, the seam #118 codes against
claude Sep 7, 2026
a09b24c
fix(gateway): wire the service's own logger into WorkerRegistry
claude Sep 7, 2026
fc0be58
fix(gateway): apply third review round's findings to the gateway skel…
claude Sep 7, 2026
8c4bf06
feat(gateway): warn when a worker joins with a lower lease.maxTtlMs
claude Sep 8, 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
87 changes: 87 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,93 @@ modes](#gateway-and-worker-modes-adr-0005) below for that topology.
- **Daemon**: owns all state, serializes all decisions. Started on demand,
reachable over a unix socket.

## Gateway and worker modes (ADR 0005)

`config.mode` selects what a daemon *is*. Everything above describes a
**worker**: the default, and what every simlock daemon was before ADR 0005 —
it owns the devices on one machine. A **gateway** owns no devices at all.
Workers connect *to* it, and it fronts them:

```
┌──────────────── gateway (mode: "gateway") ─────────────┐
agent / console ──token auth──> │ HTTP frontend + unix socket │
│ GatewayDispatcher ── worker views ──┐ │
└──────────────────────────────────────┼─────────────────┘
▲ one inbound port │
uplink ────┘ (ws upgrade on │ status.get, list.get,
(worker dials out) /v1/uplink) │ catalog.get, config.get,
▲ ▼ events.subscribe
┌────────────────────────────────┴─────┐ ┌───────────────────────────────┐
│ worker (mode: "worker", the default) │ │ worker │
│ drivers · registry · capacity · … │ │ … │
└──────────────────────────────────────┘ └───────────────────────────────┘
```

- **Workers dial out; the gateway never reaches in.** The only inbound port in
a fleet is the gateway's, so a worker behind NAT, on a laptop, or on a CI
runner joins with two config keys: `gateway.url` (the gateway's base URL,
from which `/v1/uplink` is derived) and `gateway.token`, a join token minted
on the gateway with `simlock token create --role worker`. A `worker`-role
token opens an uplink and nothing else — it is `403` on every other `/v1`
route, and an `agent`/`operator` token is `403` at `/v1/uplink`.
- **The uplink carries the existing contract, with the gateway as the protocol
client.** It is the same newline-delimited JSON framing the unix socket uses,
upgraded from the gateway's own HTTP listener. The worker's `DaemonServer`
accepts it as one more connection and grants that session the `admin` role —
not because of the transport (ADR 0003 §5 forbids that), but because *this
daemon dialled out*, to the URL in its own config, with the token from that
same file, which the gateway verified before the connection existed. An
operator who does not want a gateway administering a machine removes two
config keys.
- **A gateway is a second implementation of the contract's handlers, not a
second contract** (`src/gateway/`). Same dispatch pipeline, same operation
declarations, same role checks; the handlers read *worker views* instead of a
registry and a lease engine. Every frontend — CLI, MCP, HTTP,
`simlock/client` — works against a gateway unchanged, because they only ever
see the contract.
- **A worker view** is what the gateway knows about one worker: id (the
worker's own `instance.json` identity), label, connection state
(`connected` / `disconnected` / `incompatible`), daemon health and version,
capacity per platform, download policy, queue depth, leases, devices,
catalog, drain state, and a last-seen timestamp. It is rebuilt over the
uplink — `status.get`, `list.get`, `catalog.get`, `config.get` and
`events.subscribe` on connect, a refresh on every worker event about a lease
or a device, and a slow periodic tick as a backstop — and never persisted.
A gateway restart re-derives every view from the workers that reconnect.
- **The uplink is the reachability signal**: no polling. A closed uplink flips
the view to `disconnected` immediately and keeps its last-known state, so a
machine that vanished holding a device is still visible. The view is
forgotten only when every lease on it has passed its deadline *and*
`gateway.disconnectedRetentionMs` (default 24 h) has elapsed, or when an
operator runs `simlock worker remove` — which refuses a worker that is still
connected.
- **Aggregation.** `status.get` on a gateway returns the same shape a worker
does — capacity summed over connected workers, every device and lease
carrying a `workerId`, the gateway's own queue depth — plus a `workers`
array of views and `daemon.mode: "gateway"`. `catalog.get` is the union of
the connected workers' catalogs, each model and runtime annotated with the
workers that have it. Worker events are republished on the gateway's bus
with `workerId` added, so `simlock events --follow` against a gateway shows
the fleet.
- **What a gateway does not do.** It starts no drivers, validates no device
roots, and runs no reaper, health monitor or capacity strategy; of the
config it reads only `mode`, `http.*`, `log.*`, `lease.*`, `eventBuffer.*`
and `gateway.*` (worker-only keys warn and are ignored). It always listens
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` and
`driver.passthrough` answer `UNSUPPORTED_IN_GATEWAY_MODE` permanently: they
act on one machine's devices, and stay per-worker. The lease lifecycle
(`lease.request`/`renew`/`release`/`cancel`/`release-all`) and `device.exec`
answer the same code until the fleet queue and routing land; reads —
`lease.list`,
`list.get`, `status.get`, `catalog.get`, `events.*` — already answer for the
whole fleet.
- **The one piece of persisted gateway state** is the drained set
(`workers.json`, owner-only): drain is an operator's decision about a
machine, not a fact the machine reports, so it must survive both the
reconnect an operator is about to cause and a gateway restart.

## Contract, dispatcher, and roles (ADR 0003)

Every daemon operation is declared exactly once, in `src/contract/`: a name
Expand Down
80 changes: 72 additions & 8 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -866,13 +866,21 @@ A device currently `provisioning` or `reclaiming` carries a derived
and `list --devices` well before it crosses the threshold that would make
`doctor` flag it as stalled.

Human-oriented overview: daemon health, managed capacity (used/limit per
platform), running and reserved capacity (globally and per platform), every
managed device with its state, current leases (who — the agent id, see [Agent
identity](#agent-identity) — since when, and when each was last renewed), and
queue depth. `--json` for the structured equivalent. `overLimit` is true when a
lowered limit cannot yet be met, for example because active leases consume all
running slots.
Human-oriented overview: daemon health *and mode*, managed capacity
(used/limit per platform), running and reserved capacity (globally and per
platform), every managed device with its state, current leases (who — the agent
id, see [Agent identity](#agent-identity) — since when, and when each was last
renewed), and queue depth. `--json` for the structured equivalent. `overLimit`
is true when a lowered limit cannot yet be met, for example because active
leases consume all running slots.

Against a **gateway** (`config.mode: "gateway"`, ADR 0005) the same command
answers for the whole fleet, in the same shape: the daemon line reads
`running (gateway)`, capacity is summed across the connected workers, one line
per worker precedes the devices, and every device and lease names the worker it
lives on (`Device dev_7 on wrk_a: leased`). `--json` gains a `workers` array of
[worker views](#simlock-worker-listdrainundrainremove) and a `workerId` on each
device and lease; `daemon.mode` says which kind of daemon answered.

The daemon block carries `mode` (`"worker"` or `"gateway"`) — the one field
that tells a client which kind of daemon answered. Against a **gateway** the
Expand All @@ -893,6 +901,13 @@ agent id (see [Agent identity](#agent-identity)) that holds it, and its
`lastRenewedAt` is when the lease was last renewed (set at grant, then on
every renew) — the same field `status` renders as "last renewed".

Against a gateway, `--devices` and `--leases` list the whole fleet with a
`workerId` on every row. The device rows are the narrower shape `status`
returns rather than a worker's full registry records: a gateway has never held
a device's driver address or a driver's private data, and does not invent one.
`--rules` lists nothing on a gateway — cleanup rules are a machine's own
configuration, and a gateway runs no reaper.

## `simlock catalog [--platform <ios|android>] [--json]`

Lists what can actually be leased, so an agent can pick a valid `--device`
Expand Down Expand Up @@ -1015,7 +1030,10 @@ connected are not backfilled.
## `simlock daemon <start|stop|status|logs>`

Manage the daemon explicitly. Other commands auto-start it on demand; `daemon`
exists for operators and debugging. `stop` does not touch leases: they persist,
exists for operators and debugging. `start` starts whichever mode
`config.mode` selects — a worker (the default) or a gateway (ADR 0005) — and
`status` reports it, both in the human line (`Daemon: running (gateway)`) and
as `daemon.mode` under `--json`. `stop` does not touch leases: they persist,
and the next daemon restores each one's TTL timer from its deadline. What a
stop does end is the connections to it — a running `simlock lease` cannot
reconnect, so it exits `1` with a `DAEMON_CONNECTION_LOST` line naming a lease
Expand Down Expand Up @@ -1050,6 +1068,39 @@ handled errors. Growth is bounded: once the file passes `log.rotateBytes` it is
rotated to `daemon.log.1` (replacing any previous generation), so `logs` always
shows the current file with the immediately preceding one prepended.

## `simlock worker <list|drain|undrain|remove>`

The operator's view of a fleet (ADR 0005). Every subcommand is an admin
operation **on a gateway**; against a worker they answer `UNKNOWN_REQUEST`
(exit 2), because a worker has no worker registry to answer from.

- `list [--json]` — one line per worker: its id (the worker's own instance
identity — stable across restarts, and not its label or host name), its
label if it set one, connection state, capacity, and how many leases it
holds. A worker the gateway cannot speak to shows `incompatible` with both
protocol ranges, which is what version skew looks like from here. `--json`
prints the raw worker views, which is what the console renders.
- `drain <worker-id>` / `undrain <worker-id>` — a drained worker keeps its
existing leases and receives no new dispatches: the way to take a machine
out of service without killing anyone's device. The flag is persisted by the
gateway, so it survives both the worker's reconnect and a gateway restart —
draining a machine and then rebooting it does not quietly put it back into
rotation. Both are idempotent; an id the gateway has no view of is
`UNKNOWN_WORKER` (exit 12).
- `remove <worker-id>` — forgets a **disconnected** worker's view. A connected
one is refused with `WORKER_CONNECTED` (exit 2): it would announce itself
again on its very next frame. An id with no view answers
`{"removed":false}` rather than failing — forgetting something already
forgotten is done, not an error. A view is also forgotten automatically once
every lease on it has passed its deadline and
`gateway.disconnectedRetentionMs` (default 24 h) has elapsed.

```console
$ simlock worker list
wrk_9f2c (mac-mini-1): connected -- ios 1/2, android 0/1, 1 lease(s)
wrk_4a10 (ci-runner-3): disconnected, drained -- ios 0/4, android 0/2, 0 lease(s)
```

## `simlock config [get <key>|set <key> <value>]`

Show the effective configuration (defaults + config file + overrides): the
Expand Down Expand Up @@ -1120,6 +1171,19 @@ are daemon operations (admin role) — the daemon is the only process that
ever reads or writes `tokens.json`; the CLI is a thin client over the same
`simlock/admin` connection every other admin command uses.

Three roles:

- `agent` — the ordinary HTTP bearer token; one token is one requester.
- `operator` — everything an agent can do, plus the admin-only routes; also
accepted as the admin credential at `hello` (see
[admin credential resolution](#admin-credential-resolution)).
- `worker` — a **join token** (ADR 0005): minted on a *gateway* and put in a
worker's `gateway.token`. It authorizes exactly one thing, opening an uplink
at `GET /v1/uplink`, and is `403` on every other `/v1` route; conversely an
`agent` or `operator` token is `403` at `/v1/uplink`. Revoking one closes the
uplink, and the worker keeps retrying at its backoff cap until an operator
mints a replacement.

`create` prints the minted secret **once**, alongside the token record:

```json
Expand Down
Loading
Loading