Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
54 changes: 54 additions & 0 deletions .dependency-cruiser-known-cycles.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,33 @@
}
]
},
{
"type": "cycle",
"from": "src/daemon",
"to": "src/module-registry",
"rule": {
"severity": "error",
"name": "no-cycle-between-components"
},
"cycle": [
{
"name": "src/module-registry",
"dependencyTypes": []
},
{
"name": "src/agent-device",
"dependencyTypes": []
},
{
"name": "src/http",
"dependencyTypes": []
},
{
"name": "src/daemon",
"dependencyTypes": []
}
]
},
{
"type": "cycle",
"from": "src/gateway",
Expand Down Expand Up @@ -120,5 +147,32 @@
"dependencyTypes": []
}
]
},
{
"type": "cycle",
"from": "src/module-registry",
"to": "src/agent-device",
"rule": {
"severity": "error",
"name": "no-cycle-between-components"
},
"cycle": [
{
"name": "src/agent-device",
"dependencyTypes": []
},
{
"name": "src/http",
"dependencyTypes": []
},
{
"name": "src/daemon",
"dependencyTypes": []
},
{
"name": "src/module-registry",
"dependencyTypes": []
}
]
}
]
11 changes: 11 additions & 0 deletions .dependency-cruiser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,17 @@ export default {
from: { path: "^src/modules/", pathNot: linkTestFile },
to: { path: "^src/bus/", dependencyTypesNot: ["type-only"] },
},
{
name: "main-loads-modules-lazily",
comment:
"main.ts imports src/agent-device only by dynamic import or for types, so a daemon that has the module turned off loads none of its code",
severity: "error",
from: { path: "^src/daemon/main\\.ts$" },
to: {
dependencyTypesNot: ["type-only", "type-import", "dynamic-import"],
path: "^src/agent-device/",
},
},
direction(
"not-modules",
"core, leasing, the drivers, the gateway and the contract import nothing from the optional-module host or its registry",
Expand Down
2 changes: 1 addition & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -2196,7 +2196,7 @@ warm-pool-target-unreachable iPhone 17 / 27.0 / full: iOS 27.0 is not installed
It never changes the exit code, and `--fix` never acts on it. With
`warmPool.enabled` `false` there is none.

With `agentDevice.enabled` `true`, `doctor` reports a `module-unavailable`
With `capabilities.agent-device.enabled` `true`, `doctor` reports a `module-unavailable`
finding (its `module` is `agent-device`) when the agent-device daemon is not
running: agent-device is not on `PATH`, is too old or speaks another RPC protocol, is not agent-device at all,
cannot use its state folder, or failed to start. It carries what was found, what
Expand Down
10 changes: 5 additions & 5 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ edit yourself keeps the mode you give it until the next `config set`; run
| `gateway.url` | **Worker side.** Base URL of the gateway this worker joins, e.g. `wss://gw.example:4700`; the worker dials `<url>/v1/uplink` and upgrades it to a WebSocket. Joining grants that gateway the `admin` role on this daemon, and `gateway.token` rides on the upgrade request as a bearer credential, so use `wss://` — or plain `ws://` only over loopback or inside your own tunnel. `http://` and `https://` are rejected at load. Unset means "do not join a fleet": the default, and the only thing that changes about a joined worker. | unset |
| `gateway.token` | **Worker side.** The join token (`simlock token create --role worker`, minted on the gateway) this worker presents when it opens its uplink. Required whenever `gateway.url` is set. `simlock config` shows it; the daemon log and the `daemon.started` event show `[redacted]`. It is stored in plain text in `config.json`, which `simlock config set` writes owner-only. | unset |
| `gateway.label` | **Worker side.** Display name for this worker in `simlock worker list`, `status`, the console, and on the lease's `worker` block. Display-only: nothing routes on it and it need not be unique. | the worker's own id |
| `agentDevice.enabled` | **Both sides.** On a worker: run one agent-device daemon on this worker, started and watched by Simlock. It sees Simlock's devices and no others, refuses `boot` and `shutdown`, and keeps its files in `~/.simlock/agent-device`. If agent-device is missing, older than 0.21.28, speaks another RPC protocol or needs a newer Node than Simlock runs on, it is not started and Simlock runs on without it. A daemon a killed Simlock left behind is stopped at the next start. With `http.enabled` also `true`, the agent-device CLI reaches it at `/v1/agent-device` (see [HTTP-API.md](HTTP-API.md#v1agent-device)). On a gateway: serve `/v1/agent-device` and send each agent to a worker whose agent-device is available; the gateway runs no daemon of its own. | `false` |
| `agentDevice.leaseTtlCapMs` | **Both sides.** Caps the TTL of the leases the agent-device CLI makes and renews through Simlock: a longer `ttlMs` is lowered to it, and a lease that sends none gets it. A whole number of milliseconds from `5000` to `lease.maxTtlMs`; a value outside that range stops the daemon at start, on a gateway too. Used only when `agentDevice.enabled` is `true`. | unset (no cap) |
| `agentDevice.command` | **Worker side.** The agent-device executable: a name looked up on `PATH`, or a path. A gateway ignores it and logs a warning. Used only when `agentDevice.enabled` is `true`. | `agent-device` |
| `capabilities.agent-device.enabled` | **Both sides.** On a worker: run one agent-device daemon on this worker, started and watched by Simlock. It sees Simlock's devices and no others, refuses `boot` and `shutdown`, and keeps its files in `~/.simlock/agent-device`. If agent-device is missing, older than 0.21.28, speaks another RPC protocol or needs a newer Node than Simlock runs on, it is not started and Simlock runs on without it. A daemon a killed Simlock left behind is stopped at the next start with `capabilities.agent-device.enabled` `true`; with it off, Simlock does not look for one. With `http.enabled` also `true`, the agent-device CLI reaches it at `/v1/agent-device` (see [HTTP-API.md](HTTP-API.md#v1agent-device)). On a gateway: serve `/v1/agent-device` and send each agent to a worker whose agent-device is available; the gateway runs no daemon of its own. | `false` |
| `capabilities.agent-device.leaseTtlCapMs` | **Both sides.** Caps the TTL of the leases the agent-device CLI makes and renews through Simlock: a longer `ttlMs` is lowered to it, and a lease that sends none gets it. A whole number of milliseconds from `5000` to `lease.maxTtlMs`; a value outside that range stops the daemon at start, on a gateway too. Used only when `capabilities.agent-device.enabled` is `true`. | unset (no cap) |
| `capabilities.agent-device.command` | **Worker side.** The agent-device executable: a name looked up on `PATH`, or a path. A gateway ignores it and logs a warning. Used only when `capabilities.agent-device.enabled` is `true`. | `agent-device` |
| `exec.timeoutMs` | **Worker side.** How long one `device.exec` command (`simlock simctl` / `simlock adb` against a gateway or over HTTP) may run before the worker kills it and the operation fails with `EXEC_TIMEOUT`. Authoritative: it bounds the process that actually runs. | `10 minutes` |
| `gateway.routing` | **Gateway side.** Which routing policy places a queued request on a worker. `warm-then-free` is the only policy in v1. Among the workers that can serve the request, in order: a worker whose health is not `running`, or that has requests of its own waiting, is left out; a worker holding a ready device that fits goes first (the device's mode must fit the request, a request with no mode fits a device that serves the worker's default mode, and its runtime must be the one requested or, with none requested, the one the worker would pick: the newest runtime the model pairs with); otherwise a worker with no free running slot for the platform or overall is left out (a running device that is not leased counts as free: the worker shuts it down to make room), a worker under its RAM budget for the platform is preferred over one at it (a worker at its budget is still asked when it is the only one left), and the one with the most free running capacity for the platform wins. A worker that is left out is busy, not unable: the request waits for it. Ties go to the lowest worker id. | `warm-then-free` |
| `gateway.disconnectedRetentionMs` | **Gateway side.** How long a disconnected worker is kept (greyed, never dispatched to) before the gateway forgets it. The clock is held while the gateway still knows of gateway-issued leases on that worker, and that hold ends when the last of those leases passes its deadline. | `24 hours` |
Expand Down Expand Up @@ -162,12 +162,12 @@ off by default.

**A gateway reads a deliberately small slice of this file**: `mode`, `http.*`,
`log.*`, `lease.*`, `eventBuffer.*`, `eventLog.*`, `gateway.*` and
`agentDevice.*` (`enabled` and `leaseTtlCapMs` only) and `capabilities.*` (`enabled`). Every other key —
`capabilities.*` (`enabled`, and `leaseTtlCapMs` for agent-device). Every other key —
capacity, drivers, downloads, idle, warmPool, health, ios, android, stalledTransition —
configures devices, which a gateway does not have; each one present in a
gateway's config is reported with a warning and ignored, the same treatment an
unknown key gets. The worker-side `gateway.url`/`token`/`label` and
`agentDevice.command` are warned about there too. Nothing is stripped: the same file can be copied between a
`capabilities.agent-device.command` are warned about there too. Nothing is stripped: the same file can be copied between a
worker and a gateway with only `mode` differing, and the warning is what makes
the difference visible rather than silent.

Expand Down
12 changes: 6 additions & 6 deletions docs/HTTP-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ state, in the order it sends them (at most 16, no name twice):
`name` is lower-case letters, digits and `-`, starting with a letter, up to 32
characters. `state` is `available`, `unavailable` or `off`. `reason` is there
for `unavailable` only: 1 to 500 characters, with control characters replaced
by spaces. A worker with `agentDevice.enabled` `false` or unset sends
by spaces. A worker with `capabilities.agent-device.enabled` `false` or unset sends
`agent-device` as `off`. A status read that takes over 1 s or fails shows
`unavailable` with `could not read <name>'s status`. A gateway's own answer
leaves `capabilities` out: each worker's list is on its entry in `workers`.
Expand Down Expand Up @@ -1522,7 +1522,7 @@ has no optional module of its own, so no such route exists yet.

### `/v1/agent-device/*`

A **worker** with `agentDevice.enabled` and `http.enabled` both `true` serves
A **worker** with `capabilities.agent-device.enabled` and `http.enabled` both `true` serves
agent-device's proxy URL here. Point the agent-device CLI at it and give it
your Simlock token:

Expand All @@ -1534,7 +1534,7 @@ agent-device snapshot
agent-device close
```

With `agentDevice.enabled` `false` or unset, on a worker or a gateway, every
With `capabilities.agent-device.enabled` `false` or unset, on a worker or a gateway, every
`/v1/agent-device` URL answers `404`.

Downloads (artifacts) up to 2 GB (2147483648 bytes) are carried. A download
Expand All @@ -1543,7 +1543,7 @@ whose `content-length` is over that is answered `413` with
read. A download with no usable `content-length` that passes 2 GB is cut with
a failed response, never one that ends as if it were whole.

A **gateway** with `agentDevice.enabled` serves the same URL, and the CLI is
A **gateway** with `capabilities.agent-device.enabled` serves the same URL, and the CLI is
pointed at the gateway the same way. `open` lands only on a worker whose
agent-device is available, and the commands, heartbeats and `close` of that
lease go to the same worker. An agent on a gateway never shares an
Expand Down Expand Up @@ -1594,7 +1594,7 @@ agent-device session with an agent on a worker's own tokens. Through a gateway:
`devices`. Once agent-device answers it, Simlock renews the lease to now plus
its TTL, unless the lease already runs longer (a longer renewal by the agent
stands). A heartbeat's `ttlMs` changes that TTL, at least 5 seconds and at
most `agentDevice.leaseTtlCapMs` when that is set. While a command runs, the
most `capabilities.agent-device.leaseTtlCapMs` when that is set. While a command runs, the
lease is renewed every third of its TTL, and once more when the command ends.
An agent that stops sending heartbeats loses the device one TTL later.
- **A lease Simlock ended is not replaced quietly.** When the lease is gone
Expand Down Expand Up @@ -1629,7 +1629,7 @@ What `open` asks Simlock for:
| `--platform ios` or `android` | that platform, any device Simlock grants |
| `--target mobile`, `tv`, `desktop` | the class `phone`, `tv`, `desktop` |
| `--device "iPhone 17 Pro"` | that model, and no class: the model wins over `--target` |
| `ttlMs` | that TTL, at least 5 seconds and at most `agentDevice.leaseTtlCapMs` when set; none sent means that cap, or `lease.defaultTtlMs` without one |
| `ttlMs` | that TTL, at least 5 seconds and at most `capabilities.agent-device.leaseTtlCapMs` when set; none sent means that cap, or `lease.defaultTtlMs` without one |

Messages the route prints. The agent-device CLI reads the message in the body
(a JSON-RPC error on `/rpc`, `{ "ok": false, "error", "code" }` elsewhere), not
Expand Down
4 changes: 2 additions & 2 deletions docs/internal/agent-rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ grounds for rejecting a change even if it works.
module, and the others ask it through its surface. Where a boundary
forbids the import (the contract imports nothing, ADR 0003), the copy
is checked by a test that fails when the two disagree. The
agent-device lease TTL floor, 5 s, is set in core config and again in
agent-device's own check: the day one changes, the other is wrong.
agent-device lease TTL floor, 5 s, is set in agent-device's config check
(`definition.ts`) and again in its TTL parser (`front/ttl.ts`): the day one changes, the other is wrong.
11. **Every wait that crosses a process boundary is bounded, and the budget
does not reset.** An RPC to a worker, a handshake, a subscription, a
forwarded command: each needs an explicit deadline, and a caller that
Expand Down
2 changes: 1 addition & 1 deletion docs/internal/flows/startup.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Entry: `src/daemon/main.ts` `startDaemon` → `DaemonServer.start` (`src/daemon/
it silently; any other error fails the start. iOS only on macOS. The Android
driver starts its adb server and reattaches running emulators here.
9. Host facts read in the background; host load sampler built.
10. `ComponentInstaller`, `createCore`, agent-device supervisor, `createLeasing`,
10. `ComponentInstaller`, `createCore`, the optional-module host (`createModuleHost`; it loads each enabled module's worker half, agent-device's supervisor among them), `createLeasing`,
`core.connect(leasing.corePorts)`, `CleanupReaper`, `Nuke`, `TokenStore`,
`AdminSecretManager` (secret minted, hash in memory), `GatewayUplink` when
`gateway.url` and `gateway.token` are set, `DaemonServer`.
Expand Down
12 changes: 6 additions & 6 deletions e2e/slow-agent-device-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ function defineSuite(target: Target): void {
configOverrides: {
mode: "gateway",
http: { enabled: true, host: "127.0.0.1", port: gatewayPort },
agentDevice: { enabled: true },
capabilities: { "agent-device": { enabled: true } },
},
});
sharedEnvs.add(gateway);
Expand All @@ -170,7 +170,7 @@ function defineSuite(target: Target): void {
configOverrides: {
http: { enabled: true, host: "127.0.0.1", port: await freeLoopbackPort() },
gateway: { label: "worker", token: secret, url: `ws://127.0.0.1:${gatewayPort}` },
agentDevice: { enabled: true, command: pinned },
capabilities: { "agent-device": { enabled: true, command: pinned } },
capacity: {
config: { limits: { [platform]: { maxDevices: 1, maxRunning: 1 } } },
},
Expand Down Expand Up @@ -253,15 +253,15 @@ function defineSuite(target: Target): void {
);

it(
"with agentDevice.enabled unset on the gateway, its /v1/agent-device/health answers 404 where it answered something else while enabled",
"with capabilities.agent-device.enabled unset on the gateway, its /v1/agent-device/health answers 404 where it answered something else while enabled",
{ timeout: 600_000 },
async () => {
const probe = await newAgentToken(gateway);
const healthUrl = `http://127.0.0.1:${String(gateway.httpPort)}/v1/agent-device/health`;
const headers = { authorization: `Bearer ${probe.secret}` };
expect((await fetch(healthUrl, { headers })).status, "health while enabled").not.toBe(404);
await changeConfig(gateway, (config) => {
delete config.agentDevice;
delete config["capabilities"]?.["agent-device"];
});
await waitForWorker(
"the gateway's worker list shows the worker connected again",
Expand All @@ -273,11 +273,11 @@ function defineSuite(target: Target): void {
);

it(
"with the worker's agentDevice.enabled unset (the gateway's simlock worker list shows agent-device off), open through the gateway fails within 5 s with a message containing `no worker connected to this gateway can serve agent-device`, and no lease is made",
"with the worker's capabilities.agent-device.enabled unset (the gateway's simlock worker list shows agent-device off), open through the gateway fails within 5 s with a message containing `no worker connected to this gateway can serve agent-device`, and no lease is made",
{ timeout: 600_000 },
async () => {
await changeConfig(worker, (config) => {
delete config.agentDevice;
delete config["capabilities"]?.["agent-device"];
});
await waitForWorker(
"the gateway's worker list shows the worker connected with agent-device off",
Expand Down
Loading
Loading