Skip to content
Open
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
91 changes: 87 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,55 @@ Events are emitted post-commit only; handler failures are isolated from
emitters. See [EVENTS.md](EVENTS.md) and
[agent-rules/events.md](agent-rules/events.md).

### Driver facts reach the bus through diagnostics, never directly

Drivers must never depend on the event bus (architecture rule 5 — a driver is
not an observer of its own facts). Where a driver needs to report something
the daemon should turn into a bus event, it reports it through its own
`onDiagnostic` callback option instead — the Android driver already did this
for `snapshot-cold-boot` and unreadable device-profile sources; the iOS
driver gained the same option for component installs. `src/daemon/main.ts`
wires each driver's `onDiagnostic` at construction time
(`discoverDrivers`), bridging the diagnostic to `component.install-started` /
`component.installed` / `component.install-failed` — see
[EVENTS.md](EVENTS.md#components). This is also why those events are
attributed to the `driver-diagnostics` emitter rather than to `IosSimctlDriver`
or `AndroidDriver` directly: the driver only observed the fact, the daemon
layer is what committed it to the bus. Both drivers also thread the
requesting lease's `requesterId` (when `resolveSpec`'s caller knew one — see
`LeaseAcquisitionCoordinator#resolveAndDrive`) through the diagnostic into
the bridged event's payload, so a component install is attributable to the
request that caused it.

`component.installed` is a verified fact, not "the installer exited 0":
`xcodebuild`/`sdkmanager` reporting success only means the tool claims to
have finished, not that the thing the request actually needed — a runtime at
the requested version, paired with the requested device type for iOS; the
requested system image for Android — is now present. Both drivers re-scan
their own catalog (`simctl list` / the SDK's `system-images` tree) after the
installer returns and only report `component.installed` once that re-scan
confirms it; a re-scan that comes up empty reports `component.install-failed`
with that reason instead, and the caller still sees the same typed error it
always did (`DriverCrashError` for iOS's "still not installed" case,
`IosRuntimeUnpairedError` for a downloaded-but-unpaired runtime). Exactly one
terminal fact fires per install attempt, matching the pre-existing
`component.install-started` timing.

Before starting either install, the driver reserves free disk space against a
conservative per-component estimate (~8 GiB for an iOS runtime, ~2 GiB for an
Android system image) through a `DiskSpaceGuard` shared across every driver
(`src/daemon/main.ts` constructs one instance and passes it to each driver's
options) rather than a bare instantaneous `Filesystem#diskFree` reading: two
concurrent installs — an iOS runtime download racing an Android system-image
install, or two of either — could otherwise each observe enough free space
individually and jointly overfill the volume neither alone would have. The
guard tracks bytes reserved but not yet released, keyed per path, and checks
free space *minus* those outstanding reservations; the reservation is
released once the install settles either way. A reservation that doesn't fit
still fails fast with the same typed `InsufficientDiskSpaceError` naming
required vs. available bytes, and no `component.install-*` diagnostic fires
for a preflight failure, since no install was actually attempted.

## External APIs behind interfaces (ports)

Every external API the app touches gets its own type/interface (a *port*),
Expand Down Expand Up @@ -561,10 +610,44 @@ cannot depend on `config.log` having loaded successfully, so it builds its own
logger straight from the default log path at a fixed level, falling back to
`console.error` only if that itself fails.

`startDaemon` also subscribes `logger.child("components")` to `component.installed`
(`wireComponentInstallLogging` in `src/daemon/main.ts`) so a component simlock
installed on an agent's behalf stays attributable in `daemon.log` after the
event ring buffer resets on restart — the same durable-vs-ring-buffer split as
everything else in this section, applied to component installs specifically
because there is no registry entry or uninstall for them to be recovered from
otherwise (see "Out of scope" in the #67 issue). The log line carries
`requesterId` whenever the event payload has one, so the durable record names
which agent's request caused the install, not just that one happened.

## Device requests

Required to identify a device: **platform + device model + OS version**.
OS defaults to the newest runtime already installed on the machine. If the
requested runtime / system image is not installed, the lease fails with a
clear error unless `--allow-download` is passed (downloads are multi-GB and
must never be triggered implicitly).
OS defaults to the newest runtime already installed on the machine that can
actually run the requested model — for iOS specifically, the newest
installed runtime that both falls inside the device type's supported range
(`simctl list devicetypes`' `minRuntimeVersion`/`maxRuntimeVersion`) and
still lists the model in its `supportedDeviceTypes`, not the newest
installed runtime overall (a newer runtime can drop a model, as iOS 26 did
for iPhone XS/XR). This still resolves on a fresh Xcode install with zero
simulator runtimes present at all — an empty runtime list is a normal
starting state, not a malformed catalog, so it falls straight through to
the same "not installed, permitted to download" path as a non-empty catalog
that simply lacks a matching runtime. If the requested runtime / system
image is not installed, the lease fails with a clear error unless downloads
are permitted for that request (downloads are multi-GB and must never be
triggered implicitly). An OS version outside a model's supported range
fails immediately with the range named in the error — never as an attempted
download, since no download could make it work.

Permission comes from `config.downloads.policy`, resolved once, in the
daemon, before a request ever reaches the acquisition path: `"never"`
forbids installs outright, even over an explicit `--allow-download` /
`allow_download`; `"always"` grants it to every explicit lease request
without the caller having to ask; `"on-request"` (the default) defers to
the request's own flag, which is today's behavior byte-for-byte. Only an
explicit lease request (`LeaseEngine#request`) can carry download
permission to a driver's `resolveSpec` — warm-pool provisioning and startup
convergence reuse specs already committed to the registry and never call
`resolveSpec` themselves, so neither can trigger a download regardless of
policy.
22 changes: 20 additions & 2 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ longer dumped to stderr on every failure, only on request via `--help`.
| 12 | `NO_DRIVER` | no driver registered for the requested platform |
| 12 | `RUNTIME_MISSING` | runtime not installed and no `--allow-download` |
| 12 | `UNKNOWN_MODEL` | unknown device model for the platform |
| 12 | `INSUFFICIENT_DISK_SPACE` | not enough free disk space to install a component |
| 12 | `LICENSE_NOT_ACCEPTED` | a required license (e.g. an Android SDK license) is not accepted |
| 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 |
| 14 | — | `lease` held mode only: the daemon ended the lease without the holder asking (TTL backstop, operator `release`, or an unrecoverable device) |

Expand Down Expand Up @@ -89,8 +91,24 @@ simlock lease --platform <ios|android> --device <model> [--os <version>]
- `--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.
iOS runtimes remain Xcode-managed in v1: `--allow-download` cannot install
them; install the runtime through Xcode first.
For iOS, this runs `xcodebuild -downloadPlatform iOS` under the hood and
only reaches back to iOS 16.0 (a floor of Xcode's own downloader); older
runtimes and unknown device types (which need a newer Xcode) still require
installing/upgrading Xcode by hand. A requested `--os` outside the
device's supported runtime range (e.g. iPhone Xs above iOS 18.x) fails
immediately — no download is ever attempted for a version that could not
work regardless. For Android, this runs `sdkmanager --install`; an
unaccepted SDK license fails naming `downloads.acceptAndroidLicenses`
(config) unless that flag is set, in which case licenses are accepted
automatically and the install retried once. Both drivers check free disk
space before starting either install and fail fast, naming required vs.
available bytes, instead of risking a full disk mid-download. Every
install attempt (including a license-triggered retry) emits
`component.install-started` / `component.installed` /
`component.install-failed` on the event bus (`simlock events --follow`);
see [EVENTS.md](EVENTS.md#components). The requester's own progress stream
(below) does not yet reflect an in-flight download — see
[known-pitfalls.md](known-pitfalls.md).
- `--detach` — detached mode: print the lease result and exit; the lease is
TTL-bound and must be renewed with `simlock lease renew`.
- `--bind-pid <pid>` — held mode only: watch this pid for death instead of
Expand Down
18 changes: 18 additions & 0 deletions docs/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ in short: `subject.past-tense-fact`, emitted post-commit, facts not commands.
| `device.recovered` | device id, lease id, attempts, duration | a crashed leased device was rebooted under its existing lease and passed readiness | LeaseHealthMonitor | implemented |
| `device.recovery-failed` | device id, lease id, attempts, reason, error | recovery could not restore a leased device (absent from driver reality, provenance drift, or attempts exhausted) and its lease was released | LeaseHealthMonitor | implemented |

## Components

| Event | Payload (key fields) | Emitted when | Emitter | Status |
|---|---|---|---|---|
| `component.install-started` | platform, component id (iOS runtime version or "latest"; Android `sdkmanager` package name), requester id (when the triggering resolution knew one) | a driver is about to run `xcodebuild -downloadPlatform` / `sdkmanager --install` for a missing component, disk preflight already passed | driver-diagnostics | implemented |
| `component.installed` | platform, component id, duration, requester id | the install succeeded **and** a post-install re-scan confirmed the component the request actually needed is present (paired with the requested device type, for iOS) — never fired on a bare exit-0 | driver-diagnostics | implemented |
| `component.install-failed` | platform, component id, duration, stable error summary, requester id | the install failed, including a license-retry failure (exactly one `install-failed` per attempted install, never one per retry), **or** the installer exited 0 but the post-install re-scan could not confirm the component | driver-diagnostics | implemented |

Drivers never touch the event bus directly (architecture rule 5): both drivers report these
facts through their own `onDiagnostic` callback (mirroring the Android driver's pre-existing
diagnostic pattern), and `src/daemon/main.ts` bridges that diagnostic to the bus at driver
construction time — hence the `driver-diagnostics` emitter rather than `IosSimctlDriver` /
`AndroidDriver`. A disk-preflight failure (`InsufficientDiskSpaceError`) happens before any
diagnostic fires: no install was attempted, so nothing is reported as started or failed. See
"Device requests" and "Fresh-state strategy" in [ARCHITECTURE.md](ARCHITECTURE.md) for how a
missing component gets to this point, and `docs/known-pitfalls.md` for the requester-visible
progress gap this leaves.

## System

| Event | Payload (key fields) | Emitted when | Emitter | Status |
Expand Down
28 changes: 28 additions & 0 deletions docs/IDEAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,34 @@ boundary held.
(pre-installed certs, test apps). Offer per-pool config: reclaim by erase
(default) or by re-clone from a maintained golden device.

## Parse Apple's downloadables index for exact runtime versions (iOS)

The bounded-default download path (`IosSimctlDriver#resolveDefaultRuntime`,
`src/drivers/ios/index.ts`) can only ask `xcodebuild -downloadPlatform iOS
-buildVersion <major>` when no `--os` was given and the model's pairing range
has an upper bound — the bare major version, not an exact patch release,
because the exact downloadable versions for the installed Xcode aren't known
offline. Xcode's own downloadable-runtimes catalog (fetched by Xcode/App
Store internally) would let the driver resolve the exact newest compatible
patch release instead of gambling on the major matching a real build. Not
pursued in v1: parsing an undocumented, Apple-controlled catalog format is a
maintenance burden disproportionate to the edge case it closes (see
`docs/known-pitfalls.md`, "Component downloads").

## Requester-visible download progress

Neither driver's install (`xcodebuild -downloadPlatform`, `sdkmanager
--install`) currently reaches the requesting connection's progress stream —
see `docs/known-pitfalls.md` ("no progress push"). Closing this needs a
`downloading` stage on `LeaseProgress` (`src/core/wait-queue.ts`) and a
`Driver.resolveSpec` progress callback both drivers implement, threaded
through `LeaseAcquisitionCoordinator#resolveAndDrive` the same way
`provision`/`makeReady` already report `provisioning`/`booting`. Deferred
because it is protocol machinery (interface + CLI/MCP wire changes), not a
small addition, and the daemon-side `component.install-started` bus event
already gives an operator visibility via `simlock events --follow` even
though the waiting requester itself does not see it yet.

## Cross-machine coordination

A fleet-level broker over multiple hosts. Explicitly out of scope for v1
Expand Down
8 changes: 7 additions & 1 deletion docs/agent-rules/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ never enforce them only inside an individual rule or driver.
over a read-only registry view returning proposed actions. A rule that
executes side effects directly is a bug regardless of what it does.
4. **No implicit multi-GB downloads.** Missing runtimes / system images fail
the request unless `--allow-download` was explicitly passed.
the request unless `--allow-download` (or MCP's `allow_download`) was
explicitly passed, or `downloads.policy: "always"` is set in config --
both count as the required explicit consent, and `downloads.policy:
"never"` overrides either one back to forbidden. Warm-pool provisioning
and startup convergence never trigger a download under any policy: they
only ever reuse specs already committed to the registry, never resolve a
new one.
5. **Destructive CLI commands confirm or require `--yes`**
(`release --all`, `nuke`). `cleanup` must always support `--dry-run`.
6. **Every destructive action is attributable.** Log/emit which rule or
Expand Down
47 changes: 47 additions & 0 deletions docs/known-pitfalls.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,50 @@ destroys it (registry-only, as always). The device stays visible as
`device.purge-failed` still fires as before; `device.quarantined`,
`device.quarantine-recovered`, and `device.quarantine-abandoned` are the new
follow-up facts (see `docs/EVENTS.md`).

## Component downloads: per-request blocking, the bounded-default edge case, and no progress push

The iOS driver's `resolveSpec` (`src/drivers/ios/index.ts`) can now run
`xcodebuild -downloadPlatform iOS` when a requested runtime is missing and
downloads are permitted. Two things worth knowing about that path:

**Only the requesting lease waits.** `resolveSpec` runs inside
`LeaseAcquisitionCoordinator#resolveAndDrive`, per request, outside the
serialized decision gate and outside the FIFO head — a slow download (tens
of minutes for a ~7 GB runtime) blocks only the request that triggered it.
Concurrent requests for the *same* missing runtime are deduped behind an
in-driver promise (one `xcodebuild` invocation, all callers await it); a
request for a different model or version proceeds independently and is
never queued behind someone else's download.

**The bounded-default edge case.** When no `--os` is given and no installed
runtime pairs with the model, the driver has to guess a version to
download: unbounded models (no `maxRuntimeVersion` cap) get a plain
`-downloadPlatform iOS` (latest), but a model with a bounded max (like an
older device type whose newest compatible runtime is a specific release)
gets `-buildVersion <major from maxRuntimeVersion>` — just the major
version number, since the exact patch release isn't known offline (Apple's
downloadables index isn't parsed in v1; see `docs/IDEAS.md`). If Xcode
doesn't have a build matching that bare major version, the download fails
and the caller is told to pass `--os <version>` explicitly rather than
retrying blind.

**No requester-visible progress during a download (#67 stage 4).** The
requester's lease-progress stream (`LeaseProgress` in `src/core/wait-queue.ts`
— `queued` / `provisioning` / `booting` / `reclaiming`, relayed as CLI stderr
JSON lines and MCP `notifications/progress`) has no `downloading` stage. Both
drivers' `resolveSpec` — where a runtime or system-image install actually
happens — runs before `LeaseAcquisitionCoordinator#drive`'s provisioning
step, and `Driver.resolveSpec`'s signature carries no progress callback the
way `provision`/`makeReady` do. A held-mode CLI or MCP caller waiting on a
multi-minute install today sees nothing on the wire between its request and
either the eventual grant or a timeout; the only visibility is the daemon's
own `component.install-started` bus event (`simlock events --follow`) and log
line, neither reaching the waiting connection itself. Threading a
`downloading` stage through would mean widening the `Driver` interface
(`resolveSpec` gaining an `onProgress`-shaped option, both drivers
implementing it), a new `LeaseProgress` variant, and CLI/MCP wire changes —
real protocol machinery, not a small addition, so it was deliberately not
built in stage 4. `component.install-started`'s payload already carries
enough (`platform`, `componentId`) that a future pass wiring this through
would mostly be plumbing, not new information to invent.
2 changes: 1 addition & 1 deletion e2e/fake-driver/fake-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class OutOfProcessFakeDriver implements Driver {

async resolveSpec(
request: DeviceRequest,
options: { readonly allowDownload: boolean },
options: { readonly allowDownload: boolean; readonly requesterId?: string },
): Promise<DeviceSpec> {
const script = await this.#beforeCall("resolveSpec", [request, options]);
this.#assertKnownModel(request.model, script);
Expand Down
3 changes: 3 additions & 0 deletions src/bus/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ describe("EventBus", () => {
| "device.quarantine-stranded"
| "device.shutdown"
| "device.deleted"
| "component.install-started"
| "component.installed"
| "component.install-failed"
| "device.foreign-state-detected"
| "device.foreign-provenance-detected"
| "device.stalled-transition-detected"
Expand Down
19 changes: 19 additions & 0 deletions src/bus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ export interface EventMap {
};
"device.shutdown": { readonly deviceId: string; readonly initiator: string };
"device.deleted": { readonly deviceId: string; readonly initiator: string };
"component.install-started": {
readonly platform: string;
readonly componentId: string;
/** The requester on whose behalf this install runs, when the triggering resolution knew one. */
readonly requesterId?: string;
};
"component.installed": {
readonly platform: string;
readonly componentId: string;
readonly durationMs: number;
readonly requesterId?: string;
};
"component.install-failed": {
readonly platform: string;
readonly componentId: string;
readonly durationMs: number;
readonly error: string;
readonly requesterId?: string;
};
"daemon.started": { readonly version: string; readonly configSnapshot: unknown };
"daemon.stopping": { readonly reason: string };
"disk.pressure-detected": { readonly freeBytes: number; readonly threshold: number };
Expand Down
Loading