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
16 changes: 14 additions & 2 deletions .dependency-cruiser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ export default {
),
direction(
"no-fs-or-process",
"core, leasing, agent-device, host-load and stream reach the filesystem and processes only through injected ports",
"^src/(core|leasing|agent-device|host-load|stream)/",
"core, leasing, agent-device, host-load, stream and alerts reach the filesystem and processes only through injected ports",
"^src/(core|leasing|agent-device|host-load|stream|alerts)/",
{ dependencyTypes: ["core"], path: fsOrProcess },
),
direction(
Expand Down Expand Up @@ -139,6 +139,18 @@ export default {
"^src/(core|leasing|drivers|gateway|contract)/",
{ path: "^src/host-load/" },
),
direction(
"alerts-imports-bus-ports-core-only",
"alerts reads the bus, sends through the WebhookPoster port and takes config types from core, and imports no other component",
"^src/alerts/",
{ path: "^src/", pathNot: "^src/(alerts|bus|ports|core)/" },
),
direction(
"not-alerts",
"only the composition root, main.ts, imports the alerts module",
"^src/(?!alerts/|daemon/main\\.ts$)",
{ path: "^src/alerts/" },
),
direction(
"gateway-not-edges",
"the gateway imports no driver, HTTP, CLI or MCP module",
Expand Down
68 changes: 67 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ edit yourself keeps the mode you give it until the next `config set`; run
| `capabilities.stream.bitrateKbps` | **Worker side.** Target bitrate in kilobits per second, handed to the encoder as an average rather than a ceiling; a frame that needs more takes it. A whole number from `100` to `40000`; the iOS encoder takes nothing lower. | `1500` |
| `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.alerts.destinations` | **Gateway side.** A list of at most 16 webhook destinations the gateway sends chosen fleet events to; see [Alerts](#alerts). A bad entry stops the daemon from starting, on a gateway and on a worker alike; a worker sends nothing whatever it lists. `simlock config` shows the URLs and secrets; the daemon log, the `daemon.started` event and the event history show `[redacted]`. | `[]` (no alerts) |
| `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` |
| `gateway.execTimeoutMs` | **Gateway side.** How long the gateway waits on a proxied `device.exec` before giving up. A backstop for a worker that never answers at all — deliberately longer than the worker's own `exec.timeoutMs`, which is authoritative because that side owns the process and can kill it, so an ordinary timeout surfaces as the worker's `EXEC_TIMEOUT` rather than racing this one. | `11 minutes` |
| `gateway.leaseRequestTimeoutMs` | **Gateway side.** How long the gateway waits on a forwarded `lease.request` before giving up on that worker for this request, answering `WORKER_UNREACHABLE`. Bounds the one uplink call that otherwise had no timeout of its own, so a wedged worker cannot park a request where neither a deadline nor `lease.cancel` could ever reach it again. Generous against a cold device provision-plus-boot; well below `gateway.execTimeoutMs`, since granting a lease should never take as long as a command run against the device afterward. | `5 minutes` |
Expand Down Expand Up @@ -128,7 +129,7 @@ not apply in `mode: "gateway"`, where both keys are worker-side and are
warned about and ignored like every other worker key — a gateway is not
misconfigured by leftovers from the config it was flipped out of.
`gateway.label` is a non-empty string, `gateway.routing` one of the
registered routing policies, and `exec.timeoutMs`, `gateway.execTimeoutMs`,
registered routing policies, `gateway.alerts.destinations` the list described under [Alerts](#alerts), and `exec.timeoutMs`, `gateway.execTimeoutMs`,
`gateway.leaseRequestTimeoutMs`, and `gateway.disconnectedRetentionMs`
positive numbers.
**`mode: "gateway"` with `http.enabled: false` is rejected at load**, naming the key: a
Expand Down Expand Up @@ -239,6 +240,7 @@ does not apply to it. It reads:
|---|---|
| `mode` | to be a gateway at all |
| `gateway.routing`, `gateway.disconnectedRetentionMs`, `gateway.execTimeoutMs`, `gateway.leaseRequestTimeoutMs` | how to run the fleet |
| `gateway.alerts.destinations` | where to send fleet events |
| `http.*` | it is the fleet's contact point |
| `lease.*` | `defaultTtlMs`/`maxTtlMs` bound what its own clients may ask for, before a request is dispatched — see below |
| `log.*`, `eventBuffer.*`, `eventLog.*`, `metrics.*` | logging, the event history and the usage figures kept beside it, as anywhere |
Expand Down Expand Up @@ -312,6 +314,70 @@ because only one of them owns anything:
`drivers.android.adbServerPort` values (see below), but a gateway plus a
worker is one driver set, so there is nothing to split.

## Alerts

A gateway can send chosen fleet events to a webhook, so n8n, Zapier or your own
receiver hears about them without anyone watching the console. Set
`gateway.alerts.destinations` to a list of destinations:

```json
{
"mode": "gateway",
"gateway": {
"alerts": {
"destinations": [
{
"name": "audit",
"format": "generic",
"url": "https://n8n.example.com/hook",
"secret": "a-long-random-string",
"events": ["worker.connected", "worker.disconnected"]
}
]
}
}
}
```

| Field | Rule |
|---|---|
| `name` | Lowercase letters, digits and dashes, starting with a letter, at most 32 characters; no two destinations share one. |
| `format` | `generic`, the only format so far. |
| `url` | `https://`, or `http://` to `localhost`, `127.0.0.1` or `[::1]`; no user name or password in it. |
| `events` | A non-empty list, each event once, from: `worker.connected`, `worker.disconnected`, `worker.rejected`, `worker.drain-started`, `worker.drain-ended`, `device.quarantine-abandoned`, `device.quarantine-stranded`, `device.recovery-failed`, `device.stalled-transition-detected`, `disk.pressure-detected`, `component.install-failed`. |
| `secret` | A non-empty string the gateway signs each message with. |

An event a worker reports reaches the destination too, with `workerId` in its
payload. A key not listed above is warned about and ignored, and
`simlock config set` refuses it.

**What the receiver gets.** One `POST` per event, with the event as the body: the
same JSON `simlock events --json` prints (`id`, `seq`, `timestamp`, `event`,
`payload`, `module`). Headers:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `simlock/<version>` |
| `X-Simlock-Event` | the event name |
| `X-Simlock-Delivery` | the event `id`; the same on every try, so a receiver can drop a repeat |
| `X-Simlock-Signature` | `sha256=` and the hex HMAC-SHA256 of the raw body, keyed with the destination's `secret` |

To check a message, compute the HMAC-SHA256 of the bytes you received with the
secret and compare it to the header value in constant time. Use the raw body,
not a re-serialised copy.

**Delivery.** Each destination sends one message at a time, in the order the
gateway saw the events, and a slow destination never delays another. A try
ends after 10 seconds. A network error, a timeout, `429` or a `5xx` is tried
again 1 second after the failed try, then 5 seconds after the second: three tries
in all, then a warning in `daemon.log` ("Alert send gave up"). Any other status
that is not `2xx`, a redirect included, gives up at once; a redirect is never
followed. Up to 100 messages wait per destination; past that the oldest waiting
one is dropped with a warning. Nothing is written to disk, so a gateway restart
loses the waiting messages, and stopping the gateway abandons what is still
being sent. The log names a destination and never its URL or secret.

## Downloads

A missing iOS simulator runtime or Android system image is downloaded only
Expand Down
4 changes: 3 additions & 1 deletion docs/internal/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ worker daemon ── uplink (WebSocket to /v1/uplink, worker dials) ──┘
| core/usage | Usage figures from the history rows, read on a thread | [usage](../../src/core/usage/AGENTS.md) |
| drivers | The `Driver` port's two implementations | [drivers](../../src/drivers/AGENTS.md) · [android](../../src/drivers/android/AGENTS.md) · [android screen](../../src/drivers/android/screen/AGENTS.md) · [ios](../../src/drivers/ios/AGENTS.md) |
| gateway | Fleet queue, routing, forwarding to workers, relay | [gateway](../../src/gateway/AGENTS.md) · [routing](../../src/gateway/routing/AGENTS.md) |
| alerts | Gateway only: chosen fleet events POSTed to webhook destinations | [alerts](../../src/alerts/AGENTS.md) |
| link | Byte channels over the uplink | [link](../../src/link/AGENTS.md) |
| agent-device | Optional edge: the agent-device daemon beside a worker, its HTTP front | [agent-device](../../src/agent-device/AGENTS.md) |
| stream | Optional edge: a leased device's live screen over HTTP, relayed by a gateway | [stream](../../src/stream/AGENTS.md) |
Expand Down Expand Up @@ -73,13 +74,14 @@ Arrows point from the optional to the required (architecture rule 15).
| `past-index` | Outside a component, import only its index file (tests may also import its testing file) |
| `core-not-leasing` | Core declares a port; the composition root connects leasing to it |
| `core-not-drivers` | Core and leasing reach a platform only through the `Driver` interface |
| `no-fs-or-process` | Core, leasing, agent-device, host-load and stream use ports, never `fs` or `child_process` |
| `no-fs-or-process` | Core, leasing, agent-device, host-load, stream and alerts use ports, never `fs` or `child_process` |
| `definition-is-light` | A module's definition file imports its own module only for types or by dynamic import |
| `auth-imports-little` | `src/auth` imports only ports and contract |
| `modules-imports-little`, `modules-imports-bus-types-only` | `src/modules` imports only ports, contract and core, and the bus for types |
| `not-modules` | Core, leasing, drivers, gateway and contract import nothing from modules or module-registry |
| `not-agent-device` | Core, leasing and drivers import nothing from agent-device |
| `host-load-imports-ports-only`, `not-host-load` | host-load stands alone |
| `alerts-imports-bus-ports-core-only`, `not-alerts` | alerts imports the bus, ports and core only; only `src/daemon/main.ts` imports it |
| `not-stream`, `stream-not-edges` | Nothing required imports stream; stream imports no frontend, gateway or driver |
| `gateway-not-edges`, `gateway-only-dispatch` | Gateway imports no driver or frontend, and from daemon only `src/daemon/dispatch.ts` |
| `only-main-knows-gateway` | In daemon, only `src/daemon/main.ts` imports the gateway |
Expand Down
30 changes: 30 additions & 0 deletions src/alerts/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Alerts

Does: a gateway sends chosen fleet events to webhook destinations (ADR 0035).
Entry: `index.ts` (`createAlerts`). Built only by `../daemon/main.ts`, in the gateway branch, when
`gateway.alerts.destinations` is not empty; a worker never builds it.
Uses: bus (subscribe, types), ports (`Clock`, `Logger`, `WebhookPoster`), core (config types and
`ALERT_EVENTS`). Never imports anything else; `.dependency-cruiser.mjs` checks both directions.
Imported by: `../daemon/main.ts` only.

## Files

| File | Role |
| ------------------ | ------------------------------------------------------------------------ |
| `index.ts` | `createAlerts`: one subscription per listed event, `stop()` |
| `queue.ts` | `DestinationQueue`: one message at a time, tries, timeout, bounded wait |
| `formats/types.ts` | `AlertFormatDefinition`: `build` turns an envelope into headers and body |
| `formats/index.ts` | `ALERT_FORMAT_REGISTRY`, typed over core's `ALERT_FORMATS` |
| `formats/generic/` | The envelope as JSON, signed with HMAC-SHA256 |

## How it works

1. The bus handler puts the envelope on the queue of every destination that lists the event and returns.
2. A queue takes the oldest waiting message, builds its request once, then POSTs it: 10 s at most
per try, 3 tries, 1 s then 5 s between them. Network errors, timeouts, `429` and `5xx` are retried;
any other non-2xx status gives up at once. A queue holds 100 waiting messages and drops the oldest.
3. `stop()` aborts the try in flight, cancels timers and empties the queues; it waits for nothing.

Logs name the destination, never its URL or secret; a failed try is logged by code, never by an
error's message. A format is added by a directory under `formats/`, an entry in the registry and in
core's `ALERT_FORMATS`; the queue never names a format.
1 change: 1 addition & 0 deletions src/alerts/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
Loading
Loading