diff --git a/.dependency-cruiser-known-cycles.json b/.dependency-cruiser-known-cycles.json index 5024e5d4..c7c0bbea 100644 --- a/.dependency-cruiser-known-cycles.json +++ b/.dependency-cruiser-known-cycles.json @@ -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", @@ -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": [] + } + ] } ] diff --git a/.dependency-cruiser.mjs b/.dependency-cruiser.mjs index 67c999ea..c2d52190 100644 --- a/.dependency-cruiser.mjs +++ b/.dependency-cruiser.mjs @@ -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", diff --git a/docs/CLI.md b/docs/CLI.md index b560ee7d..6cdad893 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4631c149..ec22074b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 `/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` | @@ -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. diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 6ea44ac8..f43a8599 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -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 's status`. A gateway's own answer leaves `capabilities` out: each worker's list is on its entry in `workers`. @@ -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: @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/docs/internal/agent-rules/architecture.md b/docs/internal/agent-rules/architecture.md index 5baf2be0..5aa8dec3 100644 --- a/docs/internal/agent-rules/architecture.md +++ b/docs/internal/agent-rules/architecture.md @@ -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 diff --git a/docs/internal/flows/startup.md b/docs/internal/flows/startup.md index 146775ab..c6c795f4 100644 --- a/docs/internal/flows/startup.md +++ b/docs/internal/flows/startup.md @@ -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`. diff --git a/e2e/slow-agent-device-gateway.test.ts b/e2e/slow-agent-device-gateway.test.ts index 6d4c73ac..f3bbf6b2 100644 --- a/e2e/slow-agent-device-gateway.test.ts +++ b/e2e/slow-agent-device-gateway.test.ts @@ -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); @@ -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 } } }, }, @@ -253,7 +253,7 @@ 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); @@ -261,7 +261,7 @@ function defineSuite(target: Target): void { 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", @@ -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", diff --git a/e2e/slow-agent-device.test.ts b/e2e/slow-agent-device.test.ts index 2ab0d15c..111e9b57 100644 --- a/e2e/slow-agent-device.test.ts +++ b/e2e/slow-agent-device.test.ts @@ -139,7 +139,7 @@ function defineSuite(target: Target): void { driver: "real", configOverrides: { http: { enabled: true, host: "127.0.0.1", port: await freeLoopbackPort() }, - agentDevice: { enabled: true, command: pinned }, + capabilities: { "agent-device": { enabled: true, command: pinned } }, capacity: { config: { limits: { [platform]: { maxDevices: 1, maxRunning: 1 } } }, }, @@ -433,77 +433,83 @@ function defineSuite(target: Target): void { ); it( - "with agentDevice.leaseTtlCapMs 20000, an agent that sends no more commands gets lease.expired at most 25 s after its last command, and another agent's open then gets the device", + "with capabilities.agent-device.leaseTtlCapMs 20000, an agent that sends no more commands gets lease.expired at most 25 s after its last command, and another agent's open then gets the device", { timeout: 900_000 }, async () => { - await env.withConfig({ agentDevice: { leaseTtlCapMs: 20_000 } }, async () => { - await awaitAgentDevice(); - const a = await agent("a"); - const b = await agent("b"); - ok(await open(a)); - const [lease] = await listLeases(env); - const lastCommandAt = Date.now(); - - let expired: RecordedEvent | undefined; - await waitFor( - async () => { - expired = await eventAfter( - env, - "lease.expired", - lastCommandAt - 5_000, - forLease(lease?.id), - ); - return expired !== undefined; - }, - { timeout: 60_000, interval: 1_000, label: "lease.expired for a's lease" }, - ); - expect((expired as RecordedEvent).timestamp - lastCommandAt).toBeLessThanOrEqual(25_000); - - ok(await open(b)); - expect(await listLeases(env)).toHaveLength(1); - }); + await env.withConfig( + { capabilities: { "agent-device": { leaseTtlCapMs: 20_000 } } }, + async () => { + await awaitAgentDevice(); + const a = await agent("a"); + const b = await agent("b"); + ok(await open(a)); + const [lease] = await listLeases(env); + const lastCommandAt = Date.now(); + + let expired: RecordedEvent | undefined; + await waitFor( + async () => { + expired = await eventAfter( + env, + "lease.expired", + lastCommandAt - 5_000, + forLease(lease?.id), + ); + return expired !== undefined; + }, + { timeout: 60_000, interval: 1_000, label: "lease.expired for a's lease" }, + ); + expect((expired as RecordedEvent).timestamp - lastCommandAt).toBeLessThanOrEqual(25_000); + + ok(await open(b)); + expect(await listLeases(env)).toHaveLength(1); + }, + ); }, ); it( - "with agentDevice.leaseTtlCapMs 20000, after a graceful simlock daemon restart an old simlock lease whose agent sends nothing gets lease.expired at most 5 s after the later of its last renewal + 20 s and the moment the restarted daemon answers simlock status", + "with capabilities.agent-device.leaseTtlCapMs 20000, after a graceful simlock daemon restart an old simlock lease whose agent sends nothing gets lease.expired at most 5 s after the later of its last renewal + 20 s and the moment the restarted daemon answers simlock status", { timeout: 900_000 }, async () => { - await env.withConfig({ agentDevice: { leaseTtlCapMs: 20_000 } }, async () => { - await awaitAgentDevice(); - const a = await agent("a"); - ok(await open(a)); - const [lease] = await listLeases(env); - - await env.restart(); - const answeredAt = Date.now(); - - let expired: RecordedEvent | undefined; - await waitFor( - async () => { - expired = await eventAfter(env, "lease.expired", 0, forLease(lease?.id)); - return expired !== undefined; - }, - { timeout: 60_000, interval: 1_000, label: "lease.expired for the old lease" }, - ); - const lastRenewal = Math.max( - ...(await env.events()) - .filter( - (event) => - (event.event === "lease.granted" || event.event === "lease.renewed") && - forLease(lease?.id)(event), - ) - .map((event) => event.timestamp), - ); - expect((expired as RecordedEvent).timestamp).toBeLessThanOrEqual( - Math.max(lastRenewal + 20_000, answeredAt) + 5_000, - ); - }); + await env.withConfig( + { capabilities: { "agent-device": { leaseTtlCapMs: 20_000 } } }, + async () => { + await awaitAgentDevice(); + const a = await agent("a"); + ok(await open(a)); + const [lease] = await listLeases(env); + + await env.restart(); + const answeredAt = Date.now(); + + let expired: RecordedEvent | undefined; + await waitFor( + async () => { + expired = await eventAfter(env, "lease.expired", 0, forLease(lease?.id)); + return expired !== undefined; + }, + { timeout: 60_000, interval: 1_000, label: "lease.expired for the old lease" }, + ); + const lastRenewal = Math.max( + ...(await env.events()) + .filter( + (event) => + (event.event === "lease.granted" || event.event === "lease.renewed") && + forLease(lease?.id)(event), + ) + .map((event) => event.timestamp), + ); + expect((expired as RecordedEvent).timestamp).toBeLessThanOrEqual( + Math.max(lastRenewal + 20_000, answeredAt) + 5_000, + ); + }, + ); }, ); it( - "with agentDevice.command pointing at an agent-device that reports 0.9.2, the daemon starts and grants a plain simlock lease --detach; an agent's open fails with agent-device is not available on this simlock: found 0.9.2, need 0.21.28 or later; simlock doctor reports module-unavailable with found 0.9.2, need 0.21.28 or later", + "with capabilities.agent-device.command pointing at an agent-device that reports 0.9.2, the daemon starts and grants a plain simlock lease --detach; an agent's open fails with agent-device is not available on this simlock: found 0.9.2, need 0.21.28 or later; simlock doctor reports module-unavailable with found 0.9.2, need 0.21.28 or later", { timeout: 900_000 }, async () => { const root = await mkdtemp(join(tmpdir(), "simlock-ad-old-")); @@ -517,7 +523,7 @@ function defineSuite(target: Target): void { await chmod(entry, 0o755); const message = "found 0.9.2, need 0.21.28 or later"; - await env.withConfig({ agentDevice: { command: entry } }, async () => { + await env.withConfig({ capabilities: { "agent-device": { command: entry } } }, async () => { const a = await agent("a"); let finding: { kind: string; message: string } | undefined; await waitFor( diff --git a/src/agent-device/AGENTS.md b/src/agent-device/AGENTS.md index 05afb199..cfcb6f24 100644 --- a/src/agent-device/AGENTS.md +++ b/src/agent-device/AGENTS.md @@ -1,32 +1,34 @@ # Agent-device edge Does: runs one agent-device daemon per worker and serves the agent-device CLI at `/v1/agent-device/*`, one simlock lease per agent-device lease. -Entry: `index.ts` → `createAgentDeviceEdgeParts` (worker), `createGatewayAgentDeviceFrontParts` (gateway), `createAgentDeviceFront`, `AgentDeviceSupervisor`. +Entry: `index.ts` → `createGatewayAgentDeviceFrontParts` (gateway), `createAgentDeviceFront`, `disposeAgentDeviceEdge`, `cancelWaitingAllocates`; the worker half is `worker.ts` (`createAgentDeviceEdgeParts`, builds `AgentDeviceSupervisor`), loaded by the module host. Uses: ports, http (session, errors, token identity), contract types, core and leasing types. Never imports: gateway, drivers, `fs`, `child_process`. -Imported by: `daemon/main.ts` only (index; tests may use `testing.ts`). Pinned in `lint-rules.test.ts`. -Off unless `agentDevice.enabled` (false). Off: nothing mounted, no daemon. +Imported by: the module registry (`definition.ts` only); `daemon/main.ts` loads `index.ts` with a dynamic `import()` on a gateway that has it on (tests may use `testing.ts`). Pinned in `lint-rules.test.ts`. +Off unless `capabilities.agent-device.enabled` (false). Off: nothing mounted, no daemon. ## Files -| File | Role | -| ------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `host/agent-device-supervisor.ts` | Reap, start, watch, stop the daemon; availability state | -| `host/install.ts` | `findAgentDevice`: command → install root → daemon entry | -| `host/health.ts` | `NodeAgentDeviceHealthProbe`: `GET /health`, 5 s | -| `host/availability-findings.ts` | `agentDeviceFindings` for `simlock doctor` | -| `host/upstream.ts` | `AgentDeviceUpstream` port; `SupervisorAgentDeviceUpstream` | -| `host/host.ts` | `AgentDeviceHost`: `@agent-device/proxy`, tenant, 2 GiB download cap | -| `host/lease-end.ts` | `ReleaseScopeRecord`, `AgentDeviceLeaseEnd` (lease-end participant) | -| `front/front.ts` | The mount: role check, routes, `/rpc` dispatch by call class | -| `front/request-rules.ts` | Refusals and rewrites before anything is forwarded | -| `front/allocate.ts`, `front/heartbeat.ts`, `front/release.ts` | Lease calls ↔ simlock `lease.*` | -| `front/renewals.ts` | Renews the simlock lease while a command runs | -| `front/front-map.ts` | In-memory leases this front made, and who ended each | -| `front/devices-filter.ts`, `front/token-devices.ts` | `devices` cut to the token's leases | -| `front/host-link.ts` | `HostLink` port; `InProcessHostLink` (worker) | -| `front/relay-host-link.ts` | `RelayHostLink` (gateway); `FleetPort` | -| `shared/` | Paths, routes, RPC reader, replies, compatibility, daemon env, release scope | -| `testing.ts` | `FakeAgentDevice` (in-memory daemon), `FakeAgentDeviceHealth` | +| File | Role | +| ------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `definition.ts` | `agentDeviceModule`: config, defaults, cap check; loads `worker.ts` only when on | +| `worker.ts` | `createWorker`: supervisor, edge parts, capability, relay target, mount, hooks | +| `host/agent-device-supervisor.ts` | Reap, start, watch, stop the daemon; availability state | +| `host/install.ts` | `findAgentDevice`: command → install root → daemon entry | +| `host/health.ts` | `NodeAgentDeviceHealthProbe`: `GET /health`, 5 s | +| `host/availability-findings.ts` | `agentDeviceFindings` for `simlock doctor` | +| `host/upstream.ts` | `AgentDeviceUpstream` port; `SupervisorAgentDeviceUpstream` | +| `host/host.ts` | `AgentDeviceHost`: `@agent-device/proxy`, tenant, 2 GiB download cap | +| `host/lease-end.ts` | `ReleaseScopeRecord`, `AgentDeviceLeaseEnd` (lease-end participant) | +| `front/front.ts` | The mount: role check, routes, `/rpc` dispatch by call class | +| `front/request-rules.ts` | Refusals and rewrites before anything is forwarded | +| `front/allocate.ts`, `front/heartbeat.ts`, `front/release.ts` | Lease calls ↔ simlock `lease.*` | +| `front/renewals.ts` | Renews the simlock lease while a command runs | +| `front/front-map.ts` | In-memory leases this front made, and who ended each | +| `front/devices-filter.ts`, `front/token-devices.ts` | `devices` cut to the token's leases | +| `front/host-link.ts` | `HostLink` port; `InProcessHostLink` (worker) | +| `front/relay-host-link.ts` | `RelayHostLink` (gateway); `FleetPort` | +| `shared/` | Paths, routes, RPC reader, replies, compatibility, daemon env, release scope | +| `testing.ts` | `FakeAgentDevice` (in-memory daemon), `FakeAgentDeviceHealth` | ## How it works @@ -34,7 +36,7 @@ Supervisor start (`AgentDeviceSupervisor.start`, at daemon start): 1. Reap — pid from `/agent-device-daemon.json`, else the state dir's `daemon.json`. Signal only if `ps` args end in `--simlock-home=`; SIGTERM, check again, SIGKILL. -2. Find — `agentDevice.command` (`agent-device`) on `PATH` or as a path; `package.json` +2. Find — `capabilities.agent-device.command` (`agent-device`) on `PATH` or as a path; `package.json` names `agent-device`; a daemon entry under `dist/src`. Never downloads. 3. Check — `--version` ≥ 0.21.28 (a prerelease of it is older); Node ≥ 22.12.0 (`shared/compatibility.ts`). 4. Prepare — state dir `/agent-device` (0700, ours), policy file denying @@ -86,7 +88,7 @@ under leasing's signal (`lease.endWaitMs`). 1. CLI `open` → `POST /v1/agent-device/rpc` `agent_device.lease.allocate` with its lease id. 2. Front refuses a non-plain lease, no `--platform`, a bad `ttlMs`, or no lease id. 3. `lease.request`: `leaseId` = `requesterId` = the CLI's lease id; `--device` → model, - `--target` → class; TTL = sent, capped by `agentDevice.leaseTtlCapMs`, at least 5 s; + `--target` → class; TTL = sent, capped by `capabilities.agent-device.leaseTtlCapMs`, at least 5 s; queue wait 330 s. CLI hangs up → `lease.cancel`, retried every 1 s. 4. Grant → allocate forwarded with the device address (`udid`/`serial`), no `device`/`deviceKey`/`target`, `ttlMs` = max(24 h, 2 × `lease.maxTtlMs`). @@ -110,16 +112,16 @@ under leasing's signal (`lease.endWaitMs`). ## State and timers -| What | Value | Key | -| ---------------------------------------------- | ------------------------------- | --------------------------- | -| Ready wait / health read / `--version` | 15 s / 5 s / 10 s | none | -| Start retry; stable run before instant restart | 30 s; 60 s | none | -| Stop grace | SIGTERM 10 s, SIGKILL 2 s | none | -| Allocate queue wait | 330 s | none | -| Lease TTL cap, floor | unset, 5 s | `agentDevice.leaseTtlCapMs` | -| agent-device lease ceiling | max(24 h, 2 × `lease.maxTtlMs`) | `lease.maxTtlMs` | -| Renewal tick | TTL / 3 | lease TTL | -| Download cap; `/rpc` body | 2 GiB; 1 MiB | none | +| What | Value | Key | +| ---------------------------------------------- | ------------------------------- | ----------------------------------------- | +| Ready wait / health read / `--version` | 15 s / 5 s / 10 s | none | +| Start retry; stable run before instant restart | 30 s; 60 s | none | +| Stop grace | SIGTERM 10 s, SIGKILL 2 s | none | +| Allocate queue wait | 330 s | none | +| Lease TTL cap, floor | unset, 5 s | `capabilities.agent-device.leaseTtlCapMs` | +| agent-device lease ceiling | max(24 h, 2 × `lease.maxTtlMs`) | `lease.maxTtlMs` | +| Renewal tick | TTL / 3 | lease TTL | +| Download cap; `/rpc` body | 2 GiB; 1 MiB | none | Disk: `/agent-device/` (agent-device's own files) and the pid record. Front map, renewals and release scopes: memory only. diff --git a/src/agent-device/definition.test.ts b/src/agent-device/definition.test.ts new file mode 100644 index 00000000..ae4d5cb8 --- /dev/null +++ b/src/agent-device/definition.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; + +import { OPERATIONS } from "../contract/index.js"; +import { type Config, loadConfig } from "../core/index.js"; +import { FakeSystemStats, MemoryFilesystem } from "../ports/index.js"; +import { agentDeviceModule } from "./definition.js"; + +const configPath = "/home/agent/.simlock/config.json"; + +async function loadWith(contents: unknown, warn?: (message: string) => void): Promise { + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp("/home/agent/.simlock"); + await filesystem.writeFileAtomic(configPath, JSON.stringify(contents)); + return loadConfig({ + configPath, + filesystem, + modules: [{ ...agentDeviceModule.config, name: agentDeviceModule.name }], + systemStats: new FakeSystemStats({ cpuCount: 4, totalRamBytes: 16 * 1024 ** 3 }), + ...(warn === undefined ? {} : { warn }), + }); +} + +function section(config: Config): Record | undefined { + return (config.capabilities as Record | undefined>)[ + "agent-device" + ]; +} + +describe("agent-device's definition: capabilities.agent-device config", () => { + it("is named agent-device", () => { + expect(agentDeviceModule.name).toBe("agent-device"); + }); + + it("defaults capabilities.agent-device.enabled to false and command to agent-device, with leaseTtlCapMs unset", async () => { + const config = await loadWith({}); + + expect(section(config)).toEqual({ command: "agent-device", enabled: false }); + }); + + it("keeps an explicit capabilities.agent-device section", async () => { + const config = await loadWith({ + capabilities: { + "agent-device": { command: "/opt/ad/bin/agent-device", enabled: true }, + }, + }); + + expect(section(config)).toEqual({ command: "/opt/ad/bin/agent-device", enabled: true }); + }); + + it.each([ + ["an empty string", ""], + ["a number", 7], + ])("refuses capabilities.agent-device.command as %s, naming the key", async (_case, command) => { + await expect( + loadWith({ capabilities: { "agent-device": { command: "agent-device" } } }), + ).resolves.toBeDefined(); + + await expect(loadWith({ capabilities: { "agent-device": { command } } })).rejects.toThrow( + "capabilities.agent-device.command", + ); + }); + + it("accepts capabilities.agent-device.leaseTtlCapMs of 5000 and of lease.maxTtlMs", async () => { + const lowest = await loadWith({ capabilities: { "agent-device": { leaseTtlCapMs: 5_000 } } }); + const highest = await loadWith({ + capabilities: { "agent-device": { leaseTtlCapMs: 7_200_000 } }, + lease: { maxTtlMs: 7_200_000 }, + }); + + expect(section(lowest)?.["leaseTtlCapMs"]).toBe(5_000); + expect(section(highest)?.["leaseTtlCapMs"]).toBe(7_200_000); + }); + + it.each([ + ["4999", 4_999], + ["a fraction", 5_000.5], + ])( + "refuses capabilities.agent-device.leaseTtlCapMs of %s, naming the key", + async (_case, cap) => { + await expect( + loadWith({ capabilities: { "agent-device": { leaseTtlCapMs: 5_000 } } }), + ).resolves.toBeDefined(); + + await expect( + loadWith({ capabilities: { "agent-device": { leaseTtlCapMs: cap } } }), + ).rejects.toThrow("capabilities.agent-device.leaseTtlCapMs"); + }, + ); + + it("refuses capabilities.agent-device.leaseTtlCapMs above lease.maxTtlMs with `expected at most lease.maxTtlMs`", async () => { + await expect( + loadWith({ capabilities: { "agent-device": { leaseTtlCapMs: 14_400_000 } } }), + ).resolves.toBeDefined(); + + await expect( + loadWith({ capabilities: { "agent-device": { leaseTtlCapMs: 14_400_001 } } }), + ).rejects.toThrow( + 'Invalid config value for "capabilities.agent-device.leaseTtlCapMs": expected at most lease.maxTtlMs', + ); + }); + + it("a loaded config parsed through configSchema keeps the section", async () => { + const config = await loadWith({ + capabilities: { "agent-device": { command: "ad", enabled: true } }, + }); + + const parsed = OPERATIONS["config.get"].output.parse(config) as unknown as Config; + expect(section(parsed)).toMatchObject({ command: "ad", enabled: true }); + expect(parsed).not.toHaveProperty("agentDevice"); + }); + + it("on a gateway, reads enabled and leaseTtlCapMs without a warning, and warns that it ignores command with the generic text", async () => { + const warn = vi.fn(); + + const config = await loadWith( + { + capabilities: { "agent-device": { enabled: true, leaseTtlCapMs: 10_000 } }, + mode: "gateway", + }, + warn, + ); + expect(section(config)).toMatchObject({ enabled: true, leaseTtlCapMs: 10_000 }); + expect(warn).not.toHaveBeenCalled(); + + await loadWith( + { capabilities: { "agent-device": { command: "elsewhere" } }, mode: "gateway" }, + warn, + ); + expect(warn).toHaveBeenCalledWith( + 'Ignoring "capabilities.agent-device.command": it configures a worker, and this daemon runs in gateway mode.', + ); + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('Ignoring "capabilities.agent-device"'), + ); + }); + + it("refuses the old top-level agentDevice key as unknown, and leaves agent-device off", async () => { + const warn = vi.fn(); + + const config = await loadWith({ agentDevice: { enabled: true } }, warn); + + expect(warn).toHaveBeenCalledWith('Unknown config key: "agentDevice"'); + expect(section(config)?.["enabled"]).toBe(false); + }); +}); diff --git a/src/agent-device/definition.ts b/src/agent-device/definition.ts new file mode 100644 index 00000000..4031f01c --- /dev/null +++ b/src/agent-device/definition.ts @@ -0,0 +1,29 @@ +import { integerInRange, invalidValue, nonEmptyString, objectValidator } from "../core/index.js"; +import type { OptionalModuleDefinition } from "../modules/index.js"; + +export interface AgentDeviceModuleConfig { + readonly command: string; + readonly leaseTtlCapMs?: number; +} + +/** why: agent-device's shortest lease window, so a cap below it would shorten leases past what agent-device keeps. */ +const MIN_AGENT_DEVICE_LEASE_TTL_MS = 5_000; + +export const agentDeviceModule: OptionalModuleDefinition = { + config: { + check(section, config) { + const cap = section.leaseTtlCapMs; + if (cap !== undefined && cap > config.lease.maxTtlMs) { + throw invalidValue("capabilities.agent-device.leaseTtlCapMs", "at most lease.maxTtlMs"); + } + }, + defaults: { command: "agent-device" }, + gatewayKeys: ["leaseTtlCapMs"], + validator: objectValidator({ + command: nonEmptyString, + leaseTtlCapMs: integerInRange(MIN_AGENT_DEVICE_LEASE_TTL_MS, Number.MAX_SAFE_INTEGER), + }), + }, + loadWorker: () => import("./worker.js"), + name: "agent-device", +}; diff --git a/src/agent-device/edge.test.ts b/src/agent-device/edge.test.ts index 755fc7f5..e1e67648 100644 --- a/src/agent-device/edge.test.ts +++ b/src/agent-device/edge.test.ts @@ -15,7 +15,7 @@ import { MemoryFilesystem, MemoryLogSink, } from "../ports/index.js"; -import { FakeAgentDevice } from "./testing.js"; +import { agentDeviceModuleWith, FakeAgentDevice } from "./testing.js"; const runningDaemons: DaemonServer[] = []; const temporaryDirectories: string[] = []; @@ -162,13 +162,20 @@ async function setUp(options: SetUpOptions = {}): Promise { const logs = new MemoryLogSink(); const launch = () => startDaemon({ - agentDeviceUpstream: fake, clock, configOverrides: { - agentDevice: { - ...(options.enabled === "unset" ? {} : { enabled: options.enabled ?? true }), - ...(options.leaseTtlCapMs === undefined ? {} : { leaseTtlCapMs: options.leaseTtlCapMs }), - }, + ...(options.enabled === "unset" + ? {} + : { + capabilities: { + "agent-device": { + enabled: options.enabled ?? true, + ...(options.leaseTtlCapMs === undefined + ? {} + : { leaseTtlCapMs: options.leaseTtlCapMs }), + }, + }, + }), capacity: { config: { maxRunning: options.maxRunning ?? 4 }, strategy: "fixed" }, http: { enabled: true, host: "127.0.0.1", port }, lease: { @@ -182,6 +189,7 @@ async function setUp(options: SetUpOptions = {}): Promise { drivers, filesystem, logger: new JsonLinesLogger({ clock, level: "debug", sink: logs }), + optionalModules: [agentDeviceModuleWith({ upstream: fake })], statePath, version: "1.2.3", } as StartDaemonOptions).then((daemon) => { @@ -522,7 +530,7 @@ async function runningCommand(rig: Rig, n: number, options: Omit { - it("with agentDevice.enabled false, and unset on a gateway-mode daemon, /v1/agent-device/health answers 404, as today", async () => { + it("with capabilities.agent-device.enabled false, and unset on a gateway-mode daemon, /v1/agent-device/health answers 404, as today", async () => { const enabled = await setUp(); expect((await send(enabled, "/health")).status).toBe(200); @@ -559,7 +567,7 @@ describe("the agent-device front, through the whole worker daemon", () => { }); }); - it("with agentDevice.enabled true and agent-device unavailable, /health, /rpc and an upload each answer `agent-device is not available on this simlock: `, nothing is leased, and POST /v1/lease-requests still grants a device", async () => { + it("with capabilities.agent-device.enabled true and agent-device unavailable, /health, /rpc and an upload each answer `agent-device is not available on this simlock: `, nothing is leased, and POST /v1/lease-requests still grants a device", async () => { const rig = await setUp(); const reason = "found 0.20.3, need 0.21.28 or later"; rig.fake.setUnavailable(reason); @@ -2149,14 +2157,15 @@ describe("the agent-device front, through the whole worker daemon", () => { expect(await deadlineOf(restarted, 1)).toBe(before); }); - it("`config.get` returns `agentDevice.leaseTtlCapMs` when it is set", async () => { + it("`config.get` returns `capabilities.agent-device.leaseTtlCapMs` when it is set, and no `agentDevice` key", async () => { const rig = await setUp({ leaseTtlCapMs: 10_000 }); const config = (await rig.daemon.dispatch("config.get", {}, adminSession)) as { - readonly agentDevice: { readonly leaseTtlCapMs?: number }; + readonly capabilities: Record; }; - expect(config.agentDevice.leaseTtlCapMs).toBe(10_000); + expect(config.capabilities["agent-device"]?.leaseTtlCapMs).toBe(10_000); + expect(config).not.toHaveProperty("agentDevice"); }); }); @@ -2551,7 +2560,7 @@ describe("simlock ending a simlock lease ends the agent-device session first", ( expect(releasesReceived(rig)).toEqual([]); }); - it("with agentDevice.enabled false, release-all reclaims at once, as today", async () => { + it("with capabilities.agent-device.enabled false, release-all reclaims at once, as today", async () => { const rig = await setUp({ enabled: false }); await plainLease(rig, "plain-1"); const before = wipes(rig); @@ -3701,7 +3710,7 @@ describe("a worker's capabilities in status.get, through the whole worker daemon return (await rig.daemon.dispatch("status.get", {}, adminSession)).capabilities; } - it("a worker with agentDevice.enabled unset answers status.get with capabilities [{name: agent-device, state: off}], and so does one with it false", async () => { + it("a worker with no capabilities.agent-device section answers status.get with capabilities [{name: agent-device, state: off}], and so does one with it false", async () => { const unset = await setUp({ enabled: "unset" }); const off = await setUp({ enabled: false }); const running = await setUp(); diff --git a/src/agent-device/gateway-front.test.ts b/src/agent-device/gateway-front.test.ts index ac684dba..118fedc1 100644 --- a/src/agent-device/gateway-front.test.ts +++ b/src/agent-device/gateway-front.test.ts @@ -19,7 +19,7 @@ import { MemoryLogSink, type Filesystem, } from "../ports/index.js"; -import { FakeAgentDevice } from "./testing.js"; +import { agentDeviceModuleWith, FakeAgentDevice } from "./testing.js"; const runningDaemons: DaemonServer[] = []; const temporaryDirectories: string[] = []; @@ -206,19 +206,18 @@ async function setUp(options: SetUpOptions = {}): Promise { const logs = new MemoryLogSink(); let relay: GatewayRelay | undefined; let uplinks: WebSocketUplinkListenerFactory | undefined; - const agentDevice = - options.agentDevice === "unset" - ? {} - : { - enabled: true, - ...(options.command === undefined ? {} : { command: options.command }), - ...(options.leaseTtlCapMs === undefined ? {} : { leaseTtlCapMs: options.leaseTtlCapMs }), - }; + const agentDevice = { + enabled: true, + ...(options.command === undefined ? {} : { command: options.command }), + ...(options.leaseTtlCapMs === undefined ? {} : { leaseTtlCapMs: options.leaseTtlCapMs }), + }; const startGateway = async (): Promise => { const started = await startDaemon({ clock, configOverrides: { - agentDevice, + ...(options.agentDevice === "unset" + ? {} + : { capabilities: { "agent-device": agentDevice } }), http: { enabled: true, host: "127.0.0.1", port }, lease: { defaultTtlMs: options.defaultTtlMs ?? defaultTtlMs, @@ -235,6 +234,7 @@ async function setUp(options: SetUpOptions = {}): Promise { uplinks = created; }, logger: new JsonLinesLogger({ clock, level: "debug", sink: logs }), + optionalModules: [agentDeviceModuleWith({})], statePath: join(directory, "state.json"), version: "1.2.3", } as StartDaemonOptions); @@ -267,10 +267,9 @@ async function setUp(options: SetUpOptions = {}): Promise { ]); } const started = startDaemon({ - agentDeviceUpstream: fake, clock: workerClock, configOverrides: { - agentDevice: { enabled: spec.capability !== "off" }, + capabilities: { "agent-device": { enabled: spec.capability !== "off" } }, capacity: { config: { maxRunning: spec.maxRunning ?? 4 }, strategy: "fixed" }, gateway: { label: spec.label, token: secret, url: `ws://127.0.0.1:${String(port)}` }, ...(directPort === undefined @@ -286,6 +285,7 @@ async function setUp(options: SetUpOptions = {}): Promise { level: "debug", sink: new MemoryLogSink(), }), + optionalModules: [agentDeviceModuleWith({ upstream: fake })], statePath: join(workerDirectory, "state.json"), version: "1.2.3", } as StartDaemonOptions).then((daemon) => { @@ -687,13 +687,13 @@ function first(rig: Rig): Member { } describe("the agent-device front, through a gateway", { timeout: 60_000 }, () => { - it("with agentDevice.enabled unset on the gateway, /v1/agent-device/health answers 404, as today", async () => { + it("with no capabilities.agent-device section on the gateway, /v1/agent-device/health answers 404, as today", async () => { const rig = await setUp({ agentDevice: "unset" }); expect((await send(rig, "/health")).status).toBe(404); }); - it("with agentDevice.command set on a gateway, the gateway logs that it ignores it and starts; with agentDevice.leaseTtlCapMs above lease.maxTtlMs on a gateway, the gateway fails to start, as a worker does", async () => { + it("with capabilities.agent-device.command set on a gateway, the gateway logs that it ignores it and starts; with capabilities.agent-device.leaseTtlCapMs above lease.maxTtlMs on a gateway, the gateway fails to start, as a worker does", async () => { const rig = await setUp({ command: "agent-device-elsewhere", workers: [] }); expect((await send(rig, "/health")).status).toBe(200); @@ -701,17 +701,17 @@ describe("the agent-device front, through a gateway", { timeout: 60_000 }, () => rig.logs.records.some( (record) => record.level === "warn" && - record.message.includes("agentDevice.command") && - record.message.includes("Ignoring"), + record.message === + 'Ignoring "capabilities.agent-device.command": it configures a worker, and this daemon runs in gateway mode.', ), ).toBe(true); await expect(setUp({ leaseTtlCapMs: gatewayMaxTtlMs + 1, workers: [] })).rejects.toThrow( - /agentDevice\.leaseTtlCapMs/, + /capabilities\.agent-device\.leaseTtlCapMs/, ); }); - it("with agentDevice.enabled unset on the gateway, /rpc, upload/preflight, artifacts/ and a diagnostics URL each answer 404", async () => { + it("with no capabilities.agent-device section on the gateway, /rpc, upload/preflight, artifacts/ and a diagnostics URL each answer 404", async () => { const rig = await setUp({ agentDevice: "unset" }); const answers = await Promise.all([ @@ -1220,7 +1220,7 @@ describe("the agent-device front, through a gateway", { timeout: 60_000 }, () => }); }); - it("`agentDevice.leaseTtlCapMs` on the gateway caps the TTL of a lease made through it", async () => { + it("`capabilities.agent-device.leaseTtlCapMs` on the gateway caps the TTL of a lease made through it", async () => { const rig = await setUp({ leaseTtlCapMs: 10_000 }); const member = first(rig); diff --git a/src/agent-device/host/agent-device-supervisor.test.ts b/src/agent-device/host/agent-device-supervisor.test.ts index 7c2b2b32..ba2c5e57 100644 --- a/src/agent-device/host/agent-device-supervisor.test.ts +++ b/src/agent-device/host/agent-device-supervisor.test.ts @@ -65,7 +65,6 @@ interface Options { readonly command?: string; readonly daemonJson?: (startIndex: number) => DaemonJson; readonly driverEnvironment?: Readonly>; - readonly enabled?: boolean; readonly expectations?: readonly ScriptedProcessExpectation[]; readonly filesystem?: MemoryFilesystem; readonly home?: string; @@ -164,7 +163,7 @@ async function arrange(options: Options = {}) { PATH: "/usr/local/bin:/usr/bin", }, clock, - config: { command: options.command ?? "agent-device", enabled: options.enabled ?? true }, + config: { command: options.command ?? "agent-device" }, driverEnvironment: options.driverEnvironment ?? {}, filesystem, healthProbe: health, @@ -343,23 +342,6 @@ async function readJson(harness: Harness, path: string): Promise { - it("with agentDevice.enabled false, start() reaps a recorded daemon, starts no process, runs no --version, and availability is off before and after", async () => { - const harness = await arrange({ - enabled: false, - expectations: [psRun(900, ownLine())], - livePids: [900], - record: record(900), - }); - expect(stateOf(harness)).toBe("off"); - - await advancing(harness, harness.begin()); - - expect(harness.processes.signals).toEqual([{ pid: 900, signal: "SIGTERM" }]); - expect(commandsRun(harness)).toEqual([["ps", "-ww", "-o", "args=", "-p", "900"]]); - await expect(harness.filesystem.exists(recordPath)).resolves.toBe(false); - expect(stateOf(harness)).toBe("off"); - }); - it("enabled, availability is starting before start() settles", async () => { const harness = await arrange({ daemonJson: () => "none" }); expect(stateOf(harness)).toBe("starting"); @@ -418,7 +400,7 @@ describe("AgentDeviceSupervisor finding agent-device", () => { message: 'agent-device not found: "agent-device" is not on PATH', reason: "not-found", }); - expect(unavailable(harness).remedy).toContain("agentDevice.command"); + expect(unavailable(harness).remedy).toContain("capabilities.agent-device.command"); expect(unavailable(harness).remedy).toContain("0.21.28 or later"); for (const phrase of restartCommand) expect(unavailable(harness).remedy).toContain(phrase); expect(harness.runner.calls).toEqual([]); @@ -477,7 +459,7 @@ describe("AgentDeviceSupervisor checking the install", () => { expect(unavailable(harness).reason).toBe("not-agent-device"); expect(unavailable(harness).message).toContain(word); - expect(unavailable(harness).remedy).toContain("agentDevice.command"); + expect(unavailable(harness).remedy).toContain("capabilities.agent-device.command"); for (const phrase of restartCommand) expect(unavailable(harness).remedy).toContain(phrase); expect(daemonSpawns(harness)).toEqual([]); }); @@ -1160,22 +1142,6 @@ describe("AgentDeviceSupervisor reaping a daemon a killed simlock left", () => { expect(commandsRun(harness).filter((call) => call[0] === nodePath)).toEqual([]); }, ); - - it("logs a warning naming the pid, stays off and runs nothing 30 s later when ps fails with the edge off", async () => { - const harness = await run({ - enabled: false, - expectations: [psRun(900, "", 1)], - livePids: [900], - record: record(900), - }); - const callsAfterStart = harness.runner.calls.length; - - await advanceBy(harness, 60_000); - - expect(warnings(harness).some((line) => line.includes("900"))).toBe(true); - expect(stateOf(harness)).toBe("off"); - expect(harness.runner.calls).toHaveLength(callsAfterStart); - }); }); describe("AgentDeviceSupervisor after the daemon is running", () => { diff --git a/src/agent-device/host/agent-device-supervisor.ts b/src/agent-device/host/agent-device-supervisor.ts index 7c1bc231..e03d9538 100644 --- a/src/agent-device/host/agent-device-supervisor.ts +++ b/src/agent-device/host/agent-device-supervisor.ts @@ -57,7 +57,7 @@ export type AgentDeviceAvailability = export interface AgentDeviceSupervisorOptions { readonly baseEnvironment: NodeJS.ProcessEnv; readonly clock: Clock; - readonly config: { readonly enabled: boolean; readonly command: string }; + readonly config: { readonly command: string }; readonly driverEnvironment: Readonly>; readonly filesystem: Filesystem; readonly healthProbe: AgentDeviceHealthProbe; @@ -160,7 +160,7 @@ export class AgentDeviceSupervisor { this.#o = options; this.#stateDir = `${options.home}/agent-device`; this.#recordPath = `${options.home}/agent-device-daemon.json`; - this.#availability = options.config.enabled ? { state: "starting" } : { state: "off" }; + this.#availability = { state: "starting" }; } availability(): AgentDeviceAvailability { @@ -225,9 +225,9 @@ export class AgentDeviceSupervisor { #remedy(kind: RemedyKind): string { switch (kind) { case "not-found": - return `install agent-device ${MIN_AGENT_DEVICE_VERSION} or later, or set agentDevice.command to its path, ${RESTART}`; + return `install agent-device ${MIN_AGENT_DEVICE_VERSION} or later, or set capabilities.agent-device.command to its path, ${RESTART}`; case "not-agent-device": - return `point agentDevice.command at an agent-device install, ${RESTART}`; + return `point capabilities.agent-device.command at an agent-device install, ${RESTART}`; case "wrong-version": return `install agent-device ${MIN_AGENT_DEVICE_VERSION} or later, ${RESTART}`; case "node": @@ -257,7 +257,7 @@ export class AgentDeviceSupervisor { const realHome = await o.filesystem.realpath(o.home); this.#stateDir = `${realHome}/agent-device`; this.#recordPath = `${realHome}/agent-device-daemon.json`; - if (o.config.enabled) this.#availability = { state: "starting" }; + this.#availability = { state: "starting" }; if (!(await this.#reapStep(realHome))) return; const found = await this.#checkInstall(); if (found === undefined || this.#stopped) return; @@ -269,20 +269,13 @@ export class AgentDeviceSupervisor { } async #reapStep(realHome: string): Promise { - const o = this.#o; const reaped = await this.#reap(realHome); if (this.#stopped) return false; if (!reaped.ok) { - if (o.config.enabled) { - this.#fail("start-failed", reaped.message); - } else { - o.logger.warn(reaped.message); - this.#availability = { state: "off" }; - } + this.#fail("start-failed", reaped.message); return false; } - if (!o.config.enabled) this.#availability = { state: "off" }; - return o.config.enabled; + return true; } async #checkInstall(): Promise<{ readonly entry: string; readonly version: string } | undefined> { diff --git a/src/agent-device/host/upstream.test.ts b/src/agent-device/host/upstream.test.ts index a3662b05..119681d7 100644 --- a/src/agent-device/host/upstream.test.ts +++ b/src/agent-device/host/upstream.test.ts @@ -67,7 +67,7 @@ async function arrange(version: string) { const supervisor = new AgentDeviceSupervisor({ baseEnvironment: { HOME: "/home/operator", PATH: "/usr/local/bin:/usr/bin" }, clock, - config: { command: "agent-device", enabled: true }, + config: { command: "agent-device" }, driverEnvironment: {}, filesystem, healthProbe: health, diff --git a/src/agent-device/index.ts b/src/agent-device/index.ts index 6e696295..114d96a4 100644 --- a/src/agent-device/index.ts +++ b/src/agent-device/index.ts @@ -3,23 +3,11 @@ import type { HttpMount } from "../http/app.js"; import type { Clock, Logger } from "../ports/index.js"; import { FrontMap } from "./front/front-map.js"; import { createFront } from "./front/front.js"; -import { InProcessHostLink, type HostLink, type LinkStatus } from "./front/host-link.js"; +import type { HostLink } from "./front/host-link.js"; import { RelayHostLink, type FleetPort } from "./front/relay-host-link.js"; -import { frontReplyFor, notAvailable } from "./shared/replies.js"; import { Renewals } from "./front/renewals.js"; -import type { AgentDevicePlatform } from "./host/agent-device-supervisor.js"; -import { AgentDeviceHost } from "./host/host.js"; -import { AgentDeviceLeaseEnd, ReleaseScopeRecord } from "./host/lease-end.js"; -import type { AgentDeviceUpstream } from "./host/upstream.js"; export type { FleetPort, FleetWorker } from "./front/relay-host-link.js"; -export { agentDeviceFindings } from "./host/availability-findings.js"; -export { AgentDeviceSupervisor } from "./host/agent-device-supervisor.js"; -// fallow-ignore-next-line unused-type -- the shape `AgentDeviceSupervisor.availability()` returns, which `agentDeviceFindings` reads. -export type { AgentDeviceAvailability } from "./host/agent-device-supervisor.js"; -export type { AgentDeviceUpstream } from "./host/upstream.js"; -export { SupervisorAgentDeviceUpstream } from "./host/upstream.js"; -export { type AgentDeviceHealthProbe, NodeAgentDeviceHealthProbe } from "./host/health.js"; export interface AgentDeviceFrontParts { readonly link: HostLink; @@ -27,10 +15,7 @@ export interface AgentDeviceFrontParts { readonly renewals: Renewals; } -export interface AgentDeviceEdgeParts extends AgentDeviceFrontParts { - readonly leaseEnd: AgentDeviceLeaseEnd; -} - +// fallow-ignore-next-line unused-export -- reached through the namespace of main.ts's dynamic import. export function createGatewayAgentDeviceFrontParts( fleet: FleetPort, clock: Clock, @@ -43,52 +28,6 @@ export function createGatewayAgentDeviceFrontParts( }; } -export function createAgentDeviceEdgeParts( - upstream: AgentDeviceUpstream, - clock: Clock, - platforms: readonly AgentDevicePlatform[], -): AgentDeviceEdgeParts { - const record = new ReleaseScopeRecord(); - return { - leaseEnd: new AgentDeviceLeaseEnd(upstream, record), - link: new InProcessHostLink(new AgentDeviceHost(upstream, platforms, record)), - map: new FrontMap(clock), - renewals: new Renewals(clock), - }; -} - -// why: a relayed request's tenant is the gateway's prefixed principal, which the host treats as the requester, so the worker's own tokens never share a session with it. -export function agentDeviceRelayTarget( - parts: AgentDeviceEdgeParts, -): (request: Request, tenant: string) => Promise { - return async (request, tenant) => { - const status = await parts.link.status(); - if (status.state === "unavailable") { - return frontReplyFor(request, notAvailable(status.reason)); - } - return parts.link.forward(request, { requesterId: tenant, role: "agent" }); - }; -} - -export function agentDeviceCapabilitySource(parts: AgentDeviceEdgeParts): { - readonly name: string; - readonly read: () => Promise<{ - readonly state: "available" | "unavailable"; - readonly reason?: string; - }>; -} { - return { - name: "agent-device", - read: async () => { - // why: a status read without a lease id is never "unreachable"; only a lease has a worker to reach. - const status = (await parts.link.status()) as Exclude; - return status.state === "running" - ? { state: "available" } - : { reason: status.reason, state: "unavailable" }; - }, - }; -} - // why: a stop ends the gateway's sockets, so the calls that end then must send no renewal that races leasing teardown. export function disposeAgentDeviceEdge(parts: AgentDeviceFrontParts): void { parts.renewals.dispose(); diff --git a/src/agent-device/testing.ts b/src/agent-device/testing.ts index 2f9cda33..b5efe1fb 100644 --- a/src/agent-device/testing.ts +++ b/src/agent-device/testing.ts @@ -1,5 +1,8 @@ import { createHash, type Hash } from "node:crypto"; +import type { OptionalModuleDefinition } from "../modules/index.js"; +import { agentDeviceModule } from "./definition.js"; +import { createWorkerWith } from "./worker.js"; import type { AgentDeviceHealthProbe } from "./host/health.js"; import type { AgentDeviceUpstream, AgentDeviceUpstreamState } from "./host/upstream.js"; @@ -690,3 +693,13 @@ export class FakeAgentDevice implements AgentDeviceUpstream { return [{ leaseId, outcome: "released" }]; } } + +export function agentDeviceModuleWith(fakes: { + readonly upstream?: AgentDeviceUpstream; + readonly health?: AgentDeviceHealthProbe; +}): OptionalModuleDefinition { + return { + ...agentDeviceModule, + loadWorker: () => Promise.resolve({ createWorker: createWorkerWith(fakes) }), + } as OptionalModuleDefinition; +} diff --git a/src/agent-device/worker.ts b/src/agent-device/worker.ts new file mode 100644 index 00000000..8a8a0fd1 --- /dev/null +++ b/src/agent-device/worker.ts @@ -0,0 +1,143 @@ +import type { HttpDispatch } from "../http/dispatcher-session.js"; +import type { TokenIdentity } from "../http/token-store.js"; +import type { Clock } from "../ports/index.js"; +import type { WorkerContext, WorkerModule } from "../modules/index.js"; +import type { AgentDeviceModuleConfig } from "./definition.js"; +import { FrontMap } from "./front/front-map.js"; +import { InProcessHostLink, type LinkStatus } from "./front/host-link.js"; +import { Renewals } from "./front/renewals.js"; +import { AgentDeviceSupervisor, type AgentDevicePlatform } from "./host/agent-device-supervisor.js"; +import { agentDeviceFindings } from "./host/availability-findings.js"; +import { type AgentDeviceHealthProbe, NodeAgentDeviceHealthProbe } from "./host/health.js"; +import { AgentDeviceHost } from "./host/host.js"; +import { AgentDeviceLeaseEnd, ReleaseScopeRecord } from "./host/lease-end.js"; +import { SupervisorAgentDeviceUpstream, type AgentDeviceUpstream } from "./host/upstream.js"; +import { + cancelWaitingAllocates, + createAgentDeviceFront, + disposeAgentDeviceEdge, + type AgentDeviceFrontParts, +} from "./index.js"; +import { frontReplyFor, notAvailable } from "./shared/replies.js"; + +interface AgentDeviceEdgeParts extends AgentDeviceFrontParts { + readonly leaseEnd: AgentDeviceLeaseEnd; +} + +function createAgentDeviceEdgeParts( + upstream: AgentDeviceUpstream, + clock: Clock, + platforms: readonly AgentDevicePlatform[], +): AgentDeviceEdgeParts { + const record = new ReleaseScopeRecord(); + return { + leaseEnd: new AgentDeviceLeaseEnd(upstream, record), + link: new InProcessHostLink(new AgentDeviceHost(upstream, platforms, record)), + map: new FrontMap(clock), + renewals: new Renewals(clock), + }; +} + +// why: a relayed request's tenant is the gateway's prefixed principal, which the host treats as the requester, so the worker's own tokens never share a session with it. +function agentDeviceRelayTarget( + parts: AgentDeviceEdgeParts, +): (request: Request, tenant: string) => Promise { + return async (request, tenant) => { + const status = await parts.link.status(); + if (status.state === "unavailable") { + return frontReplyFor(request, notAvailable(status.reason)); + } + return parts.link.forward(request, { requesterId: tenant, role: "agent" }); + }; +} + +function agentDeviceCapabilitySource(parts: AgentDeviceEdgeParts): { + readonly name: string; + readonly read: () => Promise<{ + readonly state: "available" | "unavailable"; + readonly reason?: string; + }>; +} { + return { + name: "agent-device", + read: async () => { + // why: a status read without a lease id is never "unreachable"; only a lease has a worker to reach. + const status = (await parts.link.status()) as Exclude; + return status.state === "running" + ? { state: "available" } + : { reason: status.reason, state: "unavailable" }; + }, + }; +} + +export interface AgentDeviceFakes { + readonly upstream?: AgentDeviceUpstream; + readonly health?: AgentDeviceHealthProbe; +} + +/** why: the fakes are a test seam; without them the worker uses agent-device's real upstream and health probe. */ +export function createWorkerWith( + fakes: AgentDeviceFakes, +): (ctx: WorkerContext) => WorkerModule { + return (ctx) => { + const supervisor = new AgentDeviceSupervisor({ + baseEnvironment: ctx.environment, + clock: ctx.clock, + config: ctx.config, + driverEnvironment: ctx.leaseEnvironment, + filesystem: ctx.filesystem, + healthProbe: fakes.health ?? new NodeAgentDeviceHealthProbe(), + home: ctx.dataDirectory, + leaseMaxTtlMs: ctx.daemonConfig.lease.maxTtlMs, + logger: ctx.logger, + nodePath: ctx.nodePath, + nodeVersion: ctx.nodeVersion, + platforms: ctx.platforms, + processRunner: ctx.processRunner, + processSupervisor: ctx.processSupervisor, + tcpProbe: ctx.tcpProbe, + }); + const parts = createAgentDeviceEdgeParts( + fakes.upstream ?? new SupervisorAgentDeviceUpstream(supervisor), + ctx.clock, + ctx.platforms, + ); + return { + capability: agentDeviceCapabilitySource(parts), + findings: () => + agentDeviceFindings(supervisor.availability()).map(({ message, remedy }) => ({ + message, + remedy, + })), + hooks: { + // why: not awaited, since simlock's start never waits for agent-device, and a daemon that lost the socket race never gets here, so it reaps nothing. + afterSocketClaimed: () => { + if (fakes.upstream === undefined) void supervisor.start(); + }, + beforeDeviceReclaim: (ended, signal) => parts.leaseEnd.leaseEnded(ended, signal), + beforeDrain: async () => { + disposeAgentDeviceEdge(parts); + await cancelWaitingAllocates(parts, (id) => ctx.cancelPending(id)); + }, + // why: before the drivers: the Android driver stops simlock's adb server, which this daemon's agent-device uses, and a client that finds no server forks its own. + beforeDriversDispose: () => supervisor.stop(), + }, + mount: (deps) => { + const front = createAgentDeviceFront(parts, { + clock: ctx.clock, + dispatch: deps.dispatch as HttpDispatch, + logger: ctx.logger, + maxRunMs: ctx.daemonConfig.lease.maxTtlMs, + ...(ctx.config.leaseTtlCapMs === undefined + ? {} + : { leaseTtlCapMs: ctx.config.leaseTtlCapMs }), + }); + return (request, identity) => front.handle(request, identity as TokenIdentity); + }, + relayTarget: agentDeviceRelayTarget(parts), + }; + }; +} + +// fallow-ignore-next-line unused-export -- reached through the namespace of definition.ts's dynamic import. +export const createWorker = createWorkerWith({}); diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 5270f5be..8ac17bc7 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -6212,7 +6212,6 @@ function testConfig(): Config { return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 10 * gibibyte }, gateway: { diff --git a/src/contract/AGENTS.md b/src/contract/AGENTS.md index 2793a4dc..8ca8ff2b 100644 --- a/src/contract/AGENTS.md +++ b/src/contract/AGENTS.md @@ -55,7 +55,7 @@ Three identities: ## Protocol version -- Now `{ min: 38, max: 38 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`. +- Now `{ min: 39, max: 39 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`. - Every change to a contract shape bumps it by one in the same PR. Move `min` too, unless a compatibility path stays side by side. - `hello` picks the highest overlap. None → `PROTOCOL_VERSION_UNSUPPORTED` with both ranges. diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index 20965b84..1e108c64 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -1121,7 +1121,6 @@ describe("operation input/output round trips", () => { defaultModels: {}, emulator: { headless: true, gpu: "host", audio: false, bootAnimation: false }, }, - agentDevice: { command: "agent-device", enabled: false }, stalledTransition: { thresholdMultiplier: 1, minimumThresholdMs: 1 }, }; expect(OPERATIONS["config.get"].output.parse(config)).toBeDefined(); @@ -1179,7 +1178,6 @@ describe("operation input/output round trips", () => { defaultModels: {}, emulator: { headless: true, gpu: "host", audio: false, bootAnimation: false }, }, - agentDevice: { command: "agent-device", enabled: false }, stalledTransition: { thresholdMultiplier: 1, minimumThresholdMs: 1 }, }; const config = (pool: object) => ({ diff --git a/src/contract/protocol.test.ts b/src/contract/protocol.test.ts index f2b90eef..69f2cb5d 100644 --- a/src/contract/protocol.test.ts +++ b/src/contract/protocol.test.ts @@ -47,7 +47,7 @@ describe("helloRequestSchema", () => { }); describe("PROTOCOL_VERSION_RANGE", () => { - it("is one above the base branch's, for the capabilities block config.get now returns", () => { - expect(PROTOCOL_VERSION_RANGE).toEqual({ max: 38, min: 38 }); + it("is one above the base branch's, for agent-device's section moving into config.get's capabilities", () => { + expect(PROTOCOL_VERSION_RANGE).toEqual({ max: 39, min: 39 }); }); }); diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index 2f46dbe4..834da286 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -10,7 +10,7 @@ export interface ProtocolRange { const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().int() }); /** Widen only once a second version is kept alive side by side with the first; a bump that leaves no shim behind moves both ends. */ -export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 38, max: 38 }; +export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 39, max: 39 }; const LEGACY_DAEMON_PROTOCOL_VERSION = 2; diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 45e0db34..4ebc45a6 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -906,11 +906,6 @@ export const configSchema = z.object({ host: z.string(), port: z.number(), }), - agentDevice: z.object({ - enabled: z.boolean(), - command: z.string(), - leaseTtlCapMs: z.number().optional(), - }), health: z.object({ enabled: z.boolean(), probeIntervalMs: z.number(), diff --git a/src/core/cleanup/idle-destroy.test.ts b/src/core/cleanup/idle-destroy.test.ts index 9a66adf1..2d259931 100644 --- a/src/core/cleanup/idle-destroy.test.ts +++ b/src/core/cleanup/idle-destroy.test.ts @@ -8,7 +8,6 @@ const gibibyte = 1024 ** 3; const config: Config = { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 0 }, gateway: { diff --git a/src/core/cleanup/idle-shutdown.test.ts b/src/core/cleanup/idle-shutdown.test.ts index 5cdc6865..1ca548cd 100644 --- a/src/core/cleanup/idle-shutdown.test.ts +++ b/src/core/cleanup/idle-shutdown.test.ts @@ -6,7 +6,6 @@ import { idleShutdownRule } from "./idle-shutdown.js"; const config: Config = { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 0 }, gateway: { diff --git a/src/core/config.test.ts b/src/core/config.test.ts index d58f6d4f..4deccb7d 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -1773,24 +1773,6 @@ describe("loadConfig modes (ADR 0005)", () => { ); }); - it("reads agentDevice.enabled and leaseTtlCapMs on a gateway without a warning, and warns that it ignores agentDevice.command", async () => { - const warn = vi.fn(); - - const config = await load( - { - agentDevice: { enabled: true, leaseTtlCapMs: 10_000 }, - mode: "gateway", - }, - { warn }, - ); - expect(config.agentDevice).toMatchObject({ enabled: true, leaseTtlCapMs: 10_000 }); - expect(warn).not.toHaveBeenCalled(); - - await load({ agentDevice: { command: "elsewhere" }, mode: "gateway" }, { warn }); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring "agentDevice.command"')); - expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Ignoring "agentDevice"')); - }); - it("says nothing about worker keys on a worker, which is every daemon today", async () => { const warn = vi.fn(); await load({ capacity: { strategy: "fixed", config: { maxRunning: 3 } } }, { warn }); @@ -1957,87 +1939,3 @@ describe("configSchema agrees with the config the daemon actually loads", () => ); }); }); - -describe("agentDevice config", () => { - async function loadWith(contents: unknown, warn?: (message: string) => void) { - const filesystem = new MemoryFilesystem(); - await filesystem.mkdirp("/home/agent/.simlock"); - await filesystem.writeFileAtomic(configPath, JSON.stringify(contents)); - return loadConfig({ - configPath, - filesystem, - systemStats: createStats(), - ...(warn === undefined ? {} : { warn }), - }); - } - - it("defaults agentDevice.enabled to false and agentDevice.command to agent-device", async () => { - const config = await loadWith({}); - - expect(dottedValue(config, "agentDevice.enabled")).toBe(false); - expect(dottedValue(config, "agentDevice.command")).toBe("agent-device"); - }); - - it("keeps an explicit agentDevice block", async () => { - const config = await loadWith({ - agentDevice: { command: "/opt/ad/bin/agent-device", enabled: true }, - }); - - expect(dottedValue(config, "agentDevice.enabled")).toBe(true); - expect(dottedValue(config, "agentDevice.command")).toBe("/opt/ad/bin/agent-device"); - }); - - it("refuses a non-boolean agentDevice.enabled, naming the key", async () => { - await expect(loadWith({ agentDevice: { enabled: true } })).resolves.toBeDefined(); - - await expect(loadWith({ agentDevice: { enabled: "yes" } })).rejects.toThrow( - "agentDevice.enabled", - ); - }); - - it.each([ - ["an empty string", ""], - ["a number", 7], - ])("refuses agentDevice.command as %s, naming the key", async (_case, command) => { - await expect(loadWith({ agentDevice: { command: "agent-device" } })).resolves.toBeDefined(); - - await expect(loadWith({ agentDevice: { command } })).rejects.toThrow("agentDevice.command"); - }); - - it("leaves agentDevice.leaseTtlCapMs unset by default", async () => { - const config = await loadWith({}); - - expect(dottedValue(config, "agentDevice.leaseTtlCapMs")).toBeUndefined(); - }); - - it("accepts agentDevice.leaseTtlCapMs of 5000 and of lease.maxTtlMs", async () => { - const lowest = await loadWith({ agentDevice: { leaseTtlCapMs: 5_000 } }); - const highest = await loadWith({ - agentDevice: { leaseTtlCapMs: 7_200_000 }, - lease: { maxTtlMs: 7_200_000 }, - }); - - expect(dottedValue(lowest, "agentDevice.leaseTtlCapMs")).toBe(5_000); - expect(dottedValue(highest, "agentDevice.leaseTtlCapMs")).toBe(7_200_000); - }); - - it.each([ - ["4999", 4_999], - ["a fraction", 5_000.5], - ["a value above lease.maxTtlMs", 14_400_001], - ])("refuses agentDevice.leaseTtlCapMs of %s, naming the key", async (_case, leaseTtlCapMs) => { - await expect(loadWith({ agentDevice: { leaseTtlCapMs: 5_000 } })).resolves.toBeDefined(); - - await expect(loadWith({ agentDevice: { leaseTtlCapMs } })).rejects.toThrow( - "agentDevice.leaseTtlCapMs", - ); - }); - - it("a loaded config parsed through configSchema keeps both keys", async () => { - const config = await loadWith({ agentDevice: { command: "ad", enabled: true } }); - - const parsed = configSchema.parse(config) as unknown as Config; - expect(dottedValue(parsed, "agentDevice.enabled")).toBe(true); - expect(dottedValue(parsed, "agentDevice.command")).toBe("ad"); - }); -}); diff --git a/src/core/config.ts b/src/core/config.ts index e59aa945..e763d160 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -253,11 +253,6 @@ export interface Config { readonly bootAnimation: boolean; }; }; - readonly agentDevice: { - readonly enabled: boolean; - readonly command: string; - readonly leaseTtlCapMs?: number; - }; /** * ADR 0005 §3/§6. Both sides of the fleet live under one key, and which half is read depends * on `mode`: @@ -420,13 +415,13 @@ export async function loadConfig({ overrideConfig, ) as unknown as Config; validateLeaseTtls(merged); - validateAgentDeviceTtlCap(merged); validateEventLogBounds(merged); + // why: a gateway reads a module's gateway keys, so a bad value stops it too. + runModuleChecks(merged, modules); if (mode === "gateway") { warnWorkerOnlyKeys([fromFile, fromOverrides], warn, modules); requireGatewayHttp(merged); } else { - runModuleChecks(merged, modules); // Worker-side only: a gateway ignores these keys entirely (it dials nobody), so pairing // them there would fail a start over a value nothing reads. validateGatewayMembership(merged); @@ -462,16 +457,6 @@ function validateLeaseTtls(config: Config): void { } } -/** why: agent-device's shortest lease window, so a cap below it would shorten leases past what agent-device keeps. */ -const MIN_AGENT_DEVICE_LEASE_TTL_MS = 5_000; - -function validateAgentDeviceTtlCap(config: Config): void { - const cap = config.agentDevice.leaseTtlCapMs; - if (cap !== undefined && cap > config.lease.maxTtlMs) { - throw invalidValue("agentDevice.leaseTtlCapMs", "at most lease.maxTtlMs"); - } -} - /** * Cross-field check: the size cap has to hold the current file and at least one full rotated * generation, or the sweep would delete the history the moment it rotates. @@ -496,7 +481,6 @@ const GATEWAY_CONFIG_KEYS: readonly string[] = [ "eventBuffer", "eventLog", "gateway", - "agentDevice", "capabilities", ]; @@ -505,9 +489,6 @@ const GATEWAY_CONFIG_KEYS: readonly string[] = [ * gateway itself reads. */ const WORKER_ONLY_GATEWAY_KEYS: readonly string[] = ["url", "token", "label"]; -// why: `command` names the program a worker runs, and a gateway runs none. -const WORKER_ONLY_AGENT_DEVICE_KEYS: readonly string[] = ["command"]; - /** * ADR 0005 §2: "worker-only keys in a gateway's config are ignored with a warning, as unknown * keys are today". Warned per key the operator actually wrote (the file and override layers), @@ -538,15 +519,6 @@ function warnWorkerOnlyKeys( `Ignoring "gateway.${key}": it points a worker at its gateway, ` + `and this daemon runs in gateway mode.`, ); - warnWorkerOnlySubKeys( - object, - "agentDevice", - WORKER_ONLY_AGENT_DEVICE_KEYS, - warn, - (key) => - `Ignoring "agentDevice.${key}": it names the program a worker runs, ` + - `and this daemon runs in gateway mode.`, - ); warnWorkerOnlyModuleKeys(object, modules, warn); } } @@ -814,7 +786,6 @@ function defaultConfig( bootAnimation: true, }, }, - agentDevice: { enabled: false, command: "agent-device" }, gateway: { disconnectedRetentionMs: DEFAULT_DISCONNECTED_RETENTION_MS, execTimeoutMs: DEFAULT_GATEWAY_EXEC_TIMEOUT_MS, @@ -971,11 +942,6 @@ function configValidators( bootAnimation: booleanValue, }), }), - agentDevice: objectValidator({ - enabled: booleanValue, - command: nonEmptyString, - leaseTtlCapMs: integerInRange(MIN_AGENT_DEVICE_LEASE_TTL_MS, Number.MAX_SAFE_INTEGER), - }), stalledTransition: objectValidator({ thresholdMultiplier: numberAtLeast(1), minimumThresholdMs: nonNegativeNumber, diff --git a/src/core/doctor.test.ts b/src/core/doctor.test.ts index abe58416..8271af5c 100644 --- a/src/core/doctor.test.ts +++ b/src/core/doctor.test.ts @@ -3226,7 +3226,6 @@ function config(stalledTransitionOverrides: Partial return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 1 }, gateway: { diff --git a/src/core/index.ts b/src/core/index.ts index f0974228..39d0686b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -38,6 +38,7 @@ export { } from "./capacity/index.js"; export { automaticCleanupRules, type CleanupRule, type RegistryView } from "./cleanup/index.js"; export { CleanupReaper } from "./reaper.js"; +export { integerInRange, invalidValue, nonEmptyString, objectValidator } from "./validation.js"; export { BootTimeoutError, ComponentBusyError, diff --git a/src/core/nuke.test.ts b/src/core/nuke.test.ts index 95bb21a3..5a7903fa 100644 --- a/src/core/nuke.test.ts +++ b/src/core/nuke.test.ts @@ -158,7 +158,6 @@ function config(): Config { return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 1 }, gateway: { diff --git a/src/core/reaper.test.ts b/src/core/reaper.test.ts index 9ed5eca7..6e527211 100644 --- a/src/core/reaper.test.ts +++ b/src/core/reaper.test.ts @@ -54,7 +54,6 @@ function config(): Config { return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 10 * gibibyte }, gateway: { diff --git a/src/daemon-protocol/index.test.ts b/src/daemon-protocol/index.test.ts index 6e1011fb..fa4c8523 100644 --- a/src/daemon-protocol/index.test.ts +++ b/src/daemon-protocol/index.test.ts @@ -9,7 +9,7 @@ import { describe("daemon protocol", () => { it("keeps the current protocol version and newline framing", () => { - expect(DAEMON_PROTOCOL_VERSION).toBe(38); + expect(DAEMON_PROTOCOL_VERSION).toBe(39); expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n'); }); diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index 3ffc9cc1..5c888db8 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -53,7 +53,7 @@ An HTTP bind failure after a good start stops the daemon (`http-start-failed`). Principal: `hello.principal`, else `SIMLOCK_AGENT_ID`, else the daemon's pid. Both are fixed for the connection's life. 3. **Version** — ranges overlap → highest common version. `PROTOCOL_VERSION_RANGE` - is `{ min: 38, max: 38 }` (`src/contract/protocol.ts`). No overlap → the + is `{ min: 39, max: 39 }` (`src/contract/protocol.ts`). No overlap → the connection stays open, and every request but `daemon.stop` gets `PROTOCOL_VERSION_UNSUPPORTED`. 4. **Request** — one frame → one `dispatch()` call. While stopping: `DAEMON_STOPPING`. diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 9829c8fb..2fa1b3d8 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -3581,7 +3581,6 @@ function testConfig( return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, gateway: { disconnectedRetentionMs: 24 * 60 * 60_000, execTimeoutMs: 11 * 60_000, diff --git a/src/daemon/lease-requires.e2e.test.ts b/src/daemon/lease-requires.e2e.test.ts index d52ea325..56bec04f 100644 --- a/src/daemon/lease-requires.e2e.test.ts +++ b/src/daemon/lease-requires.e2e.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { FakeAgentDevice } from "../agent-device/testing.js"; +import { agentDeviceModuleWith, FakeAgentDevice } from "../agent-device/testing.js"; import { FakeDriver } from "../core/testing.js"; import { MemoryFilesystem, NoopLogger, SystemClock } from "../ports/index.js"; import type { DispatchSession } from "./dispatch.js"; @@ -62,6 +62,7 @@ describe("lease.request requires, through a gateway", () => { dataDirectory: directory, filesystem: new MemoryFilesystem(), logger: new NoopLogger(), + optionalModules: [agentDeviceModuleWith({})], statePath: join(directory, "state.json"), version: "1.0.0-e2e", }); @@ -86,9 +87,8 @@ describe("lease.request requires, through a gateway", () => { const fake = new FakeAgentDevice(); if (options.capability === "unavailable") fake.setUnavailable("down"); const daemon = await startDaemon({ - agentDeviceUpstream: fake, configOverrides: { - agentDevice: { enabled: options.capability !== "off" }, + capabilities: { "agent-device": { enabled: options.capability !== "off" } }, capacity: { config: { maxRunning: options.maxRunning ?? 2 }, strategy: "fixed" }, gateway: { label: options.label, @@ -105,6 +105,7 @@ describe("lease.request requires, through a gateway", () => { platform: "android", }), ], + optionalModules: [agentDeviceModuleWith({ upstream: fake })], filesystem: new MemoryFilesystem(), logger: new NoopLogger(), statePath: join(directory, "state.json"), diff --git a/src/daemon/main.test.ts b/src/daemon/main.test.ts index 416391dd..0aa7faf6 100644 --- a/src/daemon/main.test.ts +++ b/src/daemon/main.test.ts @@ -15,7 +15,8 @@ import { REDACTED_VALUE, Registry, } from "../core/index.js"; -import { FakeAgentDeviceHealth } from "../agent-device/testing.js"; +import { agentDeviceModule } from "../agent-device/definition.js"; +import { agentDeviceModuleWith, FakeAgentDeviceHealth } from "../agent-device/testing.js"; import { FakeDriver } from "../core/testing.js"; import { fakeModule, type FakeModule } from "../modules/testing.js"; import { IosSimctlDriver } from "../drivers/ios/index.js"; @@ -2421,6 +2422,7 @@ describe("startDaemon with the agent-device edge", () => { async function startEdge( setup: Awaited>, ports: { + readonly agentDevice?: OptionalModuleDefinition; readonly drivers?: StartDaemonOptions["drivers"]; readonly modules?: readonly FakeModule[]; readonly processRunner: ScriptedProcessRunner; @@ -2430,7 +2432,6 @@ describe("startDaemon with the agent-device edge", () => { const clock = new FakeClock(1_000); const sink = new MemoryLogSink(); const daemon = await startDaemon({ - agentDeviceHealth: new FakeAgentDeviceHealth(), clock, dataDirectory: setup.directory, drivers: ports.drivers ?? [ @@ -2439,13 +2440,12 @@ describe("startDaemon with the agent-device edge", () => { filesystem: setup.filesystem, hostInfo: new FakeHostInfo({ arch: "arm64", os: "macOS", osVersion: "15.5" }), logger: new JsonLinesLogger({ clock, level: "debug", sink }), - ...(ports.modules === undefined - ? {} - : { - optionalModules: ports.modules.map( - (module) => module.definition as OptionalModuleDefinition, - ), - }), + optionalModules: [ + ports.agentDevice ?? agentDeviceModuleWith({ health: new FakeAgentDeviceHealth() }), + ...(ports.modules ?? []).map( + (module) => module.definition as OptionalModuleDefinition, + ), + ], processRunner: ports.processRunner, processSupervisor: ports.processSupervisor, statePath: join(setup.directory, "state.json"), @@ -2456,10 +2456,9 @@ describe("startDaemon with the agent-device edge", () => { return { daemon, sink }; } - it("runs a module's beforeDriversDispose after agent-device's child is stopped and before the drivers are disposed", async () => { + it("runs a module's beforeDriversDispose and stops agent-device's child, both before the drivers are disposed", async () => { const setup = await setUp({ - agentDevice: { command, enabled: true }, - capabilities: { alpha: { enabled: true } }, + capabilities: { "agent-device": { command, enabled: true }, alpha: { enabled: true } }, }); const events: string[] = []; const processSupervisor = new KillingSupervisor(); @@ -2500,15 +2499,15 @@ describe("startDaemon with the agent-device edge", () => { await daemon.stop("test"); - expect(events).toEqual([ + expect(events.at(-1)).toBe("driver disposed"); + expect(events.slice(0, -1).sort()).toEqual([ "agent-device exited", "module beforeDriversDispose", - "driver disposed", ]); }); it("resolves and grants a lease to an ordinary client while agent-device's /health never answers, and stops the child before the drivers are disposed", async () => { - const setup = await setUp({ agentDevice: { command, enabled: true } }); + const setup = await setUp({ capabilities: { "agent-device": { command, enabled: true } } }); const events: string[] = []; const processSupervisor = new KillingSupervisor(); const runner = versionAndDaemon(setup.directory); @@ -2547,7 +2546,7 @@ describe("startDaemon with the agent-device edge", () => { }); async function spawnedEnvironment(drivers: (clock: FakeClock) => StartDaemonOptions["drivers"]) { - const setup = await setUp({ agentDevice: { command, enabled: true } }); + const setup = await setUp({ capabilities: { "agent-device": { command, enabled: true } } }); const runner = versionAndDaemon(setup.directory); await startEdge(setup, { drivers: drivers(new FakeClock(1_000)), @@ -2587,7 +2586,7 @@ describe("startDaemon with the agent-device edge", () => { it("still starts and grants a lease when agent-device is not found", async () => { const setup = await setUp({ - agentDevice: { command: "/opt/missing/agent-device", enabled: true }, + capabilities: { "agent-device": { command: "/opt/missing/agent-device", enabled: true } }, }); const runner = new ScriptedProcessRunner([]); @@ -2606,7 +2605,7 @@ describe("startDaemon with the agent-device edge", () => { it("builds no supervisor in gateway mode: no ps and no agent-device process run, and no signal is sent", async () => { const setup = await setUp( { - agentDevice: { command, enabled: true }, + capabilities: { "agent-device": { command, enabled: true } }, http: { enabled: true, host: "127.0.0.1", port: await freeLoopbackPort() }, mode: "gateway", }, @@ -2622,14 +2621,97 @@ describe("startDaemon with the agent-device edge", () => { expect(processSupervisor.signals).toEqual([]); }); + it('a worker with no capabilities["agent-device"] section, and one with enabled false, answer status.get with { name: agent-device, state: off }', async () => { + const admin = { + manageEventSubscription: () => undefined, + principal: "op", + role: "admin", + } as const; + for (const config of [{}, { capabilities: { "agent-device": { enabled: false } } }]) { + const setup = await setUp(config); + const { daemon } = await startEdge(setup, { + processRunner: new ScriptedProcessRunner([]), + processSupervisor: new FakeProcessSupervisor(), + }); + + const status = await daemon.dispatch("status.get", {}, admin); + + expect(status.capabilities).toEqual([{ name: "agent-device", state: "off" }]); + } + }); + + it("a worker with agent-device off never calls agent-device's loadWorker", async () => { + let loads = 0; + const spying: OptionalModuleDefinition = { + ...agentDeviceModule, + loadWorker: () => { + loads += 1; + return agentDeviceModule.loadWorker(); + }, + } as OptionalModuleDefinition; + const setup = await setUp({ capabilities: { "agent-device": { enabled: false } } }); + const { daemon } = await startEdge(setup, { + agentDevice: spying, + processRunner: new ScriptedProcessRunner([]), + processSupervisor: new FakeProcessSupervisor(), + }); + await daemon.stop("test"); + + expect(loads).toBe(0); + + const on = await setUp({ capabilities: { "agent-device": { command, enabled: true } } }); + await startEdge(on, { + agentDevice: spying, + processRunner: versionAndDaemon(on.directory), + processSupervisor: new FakeProcessSupervisor(), + }); + expect(loads).toBe(1); + }); + + it('a worker whose config has only the old agentDevice key warns `Unknown config key: "agentDevice"` and reports agent-device off', async () => { + const admin = { + manageEventSubscription: () => undefined, + principal: "op", + role: "admin", + } as const; + const setup = await setUp({ agentDevice: { command, enabled: true } }); + + const { daemon, sink } = await startEdge(setup, { + processRunner: new ScriptedProcessRunner([]), + processSupervisor: new FakeProcessSupervisor(), + }); + + expect(sink.records.map((record) => record.message)).toContain( + 'Unknown config key: "agentDevice"', + ); + expect((await daemon.dispatch("status.get", {}, admin)).capabilities).toEqual([ + { name: "agent-device", state: "off" }, + ]); + }); + + it("a worker with agent-device off starts no supervisor: no ps runs and no signal is sent, even with a recorded daemon on disk", async () => { + const setup = await setUp( + { capabilities: { "agent-device": { command, enabled: false } } }, + { record: { pid: 900 } }, + ); + const runner = new ScriptedProcessRunner([]); + const processSupervisor = new FakeProcessSupervisor([900]); + + await startEdge(setup, { processRunner: runner, processSupervisor }); + await pollUntil(() => false, 100); + + expect(runner.calls).toEqual([]); + expect(processSupervisor.signals).toEqual([]); + }); + it("runs no ps and sends no signal in a second daemon on the same home that loses the socket claim", async () => { - const first = await setUp({ agentDevice: { enabled: false } }); + const first = await setUp({ capabilities: { "agent-device": { enabled: false } } }); await startEdge(first, { processRunner: new ScriptedProcessRunner([]), processSupervisor: new FakeProcessSupervisor(), }); const second = await setUp( - { agentDevice: { command, enabled: true } }, + { capabilities: { "agent-device": { command, enabled: true } } }, { directory: first.directory, record: { pid: 900 } }, ); const runner = new ScriptedProcessRunner([]); @@ -2657,7 +2739,7 @@ describe("startDaemon with the agent-device edge", () => { report.findings.find((finding) => finding.kind === "module-unavailable"); it("reports module-unavailable for agent-device, naming the found and needed versions, and an ordinary client is still granted a lease", async () => { - const setup = await setUp({ agentDevice: { command, enabled: true } }); + const setup = await setUp({ capabilities: { "agent-device": { command, enabled: true } } }); const runner = new ScriptedProcessRunner([ { match: { args: [realCli, "--version"], command: process.execPath }, @@ -2690,8 +2772,10 @@ describe("startDaemon with the agent-device edge", () => { it("lists agent-device's module-unavailable finding before a module's own", async () => { const setup = await setUp({ - agentDevice: { command: "/opt/missing/agent-device", enabled: true }, - capabilities: { alpha: { enabled: true } }, + capabilities: { + "agent-device": { command: "/opt/missing/agent-device", enabled: true }, + alpha: { enabled: true }, + }, }); const { daemon } = await startEdge(setup, { modules: [ @@ -2719,9 +2803,9 @@ describe("startDaemon with the agent-device edge", () => { .toEqual(["agent-device", "alpha"]); }); - it("reports module-unavailable for agent-device, naming the command when agentDevice.command is found nowhere", async () => { + it("reports module-unavailable for agent-device, naming the command when capabilities.agent-device.command is found nowhere", async () => { const setup = await setUp({ - agentDevice: { command: "/opt/missing/agent-device", enabled: true }, + capabilities: { "agent-device": { command: "/opt/missing/agent-device", enabled: true } }, }); const { daemon } = await startEdge(setup, { processRunner: new ScriptedProcessRunner([]), @@ -2743,8 +2827,8 @@ describe("startDaemon with the agent-device edge", () => { }); }, 10_000); - it("reports no module-unavailable finding with agentDevice.enabled false", async () => { - const setup = await setUp({ agentDevice: { enabled: false } }); + it("reports no module-unavailable finding with capabilities.agent-device.enabled false", async () => { + const setup = await setUp({ capabilities: { "agent-device": { enabled: false } } }); const { daemon } = await startEdge(setup, { processRunner: new ScriptedProcessRunner([]), processSupervisor: new FakeProcessSupervisor(), @@ -2756,7 +2840,7 @@ describe("startDaemon with the agent-device edge", () => { }); it("reports no module-unavailable finding while agent-device's /health never answers and the readiness budget has not run out", async () => { - const setup = await setUp({ agentDevice: { command, enabled: true } }); + const setup = await setUp({ capabilities: { "agent-device": { command, enabled: true } } }); const runner = versionAndDaemon(setup.directory); const { daemon } = await startEdge(setup, { processRunner: runner, diff --git a/src/daemon/main.ts b/src/daemon/main.ts index 15aee47a..1e9add52 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -2,23 +2,8 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { - agentDeviceCapabilitySource, - agentDeviceFindings, - agentDeviceRelayTarget, - AgentDeviceSupervisor, - cancelWaitingAllocates, - createAgentDeviceEdgeParts, - disposeAgentDeviceEdge, - createAgentDeviceFront, - createGatewayAgentDeviceFrontParts, - SupervisorAgentDeviceUpstream, - type AgentDeviceHealthProbe, - type AgentDeviceUpstream, - type FleetPort, - type FleetWorker, - NodeAgentDeviceHealthProbe, -} from "../agent-device/index.js"; +import type { FleetPort, FleetWorker } from "../agent-device/index.js"; +import type { AgentDeviceModuleConfig } from "../agent-device/definition.js"; import { EVENT_FILE_NAME, EventBus, EventHistory, type EventBusLogger } from "../bus/index.js"; import { fitHostFacts, fitHostLoad } from "../contract/index.js"; import { HostLoadSampler } from "../host-load/index.js"; @@ -129,8 +114,6 @@ import type { RelayTargets } from "./relay.js"; import { createCredentialRoleResolver } from "./session.js"; export interface StartDaemonOptions { - readonly agentDeviceHealth?: AgentDeviceHealthProbe; - readonly agentDeviceUpstream?: AgentDeviceUpstream; readonly clock?: Clock; readonly configOverrides?: ConfigOverrides; readonly configPath?: string; @@ -335,10 +318,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise [ - ...agentDeviceFindings(agentDevice.availability()), - ...moduleHost.findings(), - ], + optionalModules: () => moduleHost.findings(), prerequisiteChecks, registry, systemStats, @@ -347,31 +327,6 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise driver.virtualLeaseEnvironment()), ); - const agentDevice = new AgentDeviceSupervisor({ - baseEnvironment: process.env, - clock, - config: config.agentDevice, - driverEnvironment: leaseEnvironment, - filesystem, - healthProbe: options.agentDeviceHealth ?? new NodeAgentDeviceHealthProbe(), - home: dataDirectory, - leaseMaxTtlMs: config.lease.maxTtlMs, - logger: logger.child("agent-device"), - nodePath: process.execPath, - nodeVersion: process.versions.node, - platforms: drivers.map((driver) => driver.platform), - processRunner, - processSupervisor, - tcpProbe, - }); - // why: the edge needs nothing from leasing, so it is built here, with the supervisor it reads. - const agentDeviceParts = config.agentDevice.enabled - ? createAgentDeviceEdgeParts( - options.agentDeviceUpstream ?? new SupervisorAgentDeviceUpstream(agentDevice), - clock, - drivers.map((driver) => driver.platform), - ) - : undefined; // why: a module's context is built before leasing exists, so `cancelPending` answers only once leasing does. let cancelPendingRequest: ((requesterId: string) => Promise) | undefined; const moduleHost = await createModuleHost({ @@ -399,7 +354,6 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise core.closeWarmPool(), // why: the source's name is the config key's; the core names no module, so the composition supplies it. - capabilitySources: [ - agentDeviceParts === undefined - ? { name: "agent-device", read: async () => ({ state: "off" }) } - : agentDeviceCapabilitySource(agentDeviceParts), - ...moduleHost.capabilitySources, - ], + capabilitySources: [...moduleHost.capabilitySources], capacity: core.capacityReader, warmPool: core.warmPoolReader, catalog: core.catalog, @@ -567,9 +513,6 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise driver.dispose?.())); for (const [index, disposal] of disposals.entries()) { @@ -618,10 +558,6 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise { - if (agentDeviceParts !== undefined) { - disposeAgentDeviceEdge(agentDeviceParts); - await cancelWaitingAllocates(agentDeviceParts, (id) => leasing.cancelPending(id)); - } await moduleHost.beforeDrain(); await gatewayStarted.catch(() => undefined); await stopHttpGateway?.(); @@ -642,9 +578,6 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise { socketClaimed = true; moduleHost.afterSocketClaimed(); - // why: not awaited, since simlock's start never waits for agent-device, and a daemon that lost - // the socket race never gets here, so it reaps nothing. - if (options.agentDeviceUpstream === undefined) void agentDevice.start(); // why: dialled here, once the socket is claimed and `#readyPromise` is set. The gateway's first // `status.get` never parks (it still answers `starting` during convergence), but its // `events.subscribe` does park on startup readiness, so the refresh that follows the @@ -679,22 +612,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise & { readonly enabled?: boolean }) + | undefined; + // why: loaded only when turned on, so no daemon loads agent-device's code through a static import. + const agentDeviceModule = + agentDeviceSection?.enabled === true ? await import("../agent-device/index.js") : undefined; + const agentDeviceParts = agentDeviceModule + ? agentDeviceModule.createGatewayAgentDeviceFrontParts( agentDeviceFleetPort({ instanceId, leaseIndex, @@ -966,9 +890,11 @@ async function startGatewayDaemon(options: GatewayDaemonOptions): Promise { - if (agentDeviceParts !== undefined) { - disposeAgentDeviceEdge(agentDeviceParts); - await cancelWaitingAllocates(agentDeviceParts, (id) => fleetCoordinator.cancelPending(id)); + if (agentDeviceModule !== undefined && agentDeviceParts !== undefined) { + agentDeviceModule.disposeAgentDeviceEdge(agentDeviceParts); + await agentDeviceModule.cancelWaitingAllocates(agentDeviceParts, (id) => + fleetCoordinator.cancelPending(id), + ); } await httpStarted.catch(() => undefined); await stopHttpGateway?.(); @@ -1000,17 +926,17 @@ async function startGatewayDaemon(options: GatewayDaemonOptions): Promise { expect({ loads: module.loads, creates: module.creates }).toEqual({ creates: 1, loads: 1 }); }); - it("the capability list is agent-device's entry first, then registered modules in registry order", async () => { + it("the capability list follows registry order, with agent-device as a registered module", async () => { const { daemon } = await start({ - extra: { agentDeviceUpstream: new FakeAgentDevice() }, - modules: definitions(fakeModule({ name: "zulu" }), fakeModule({ name: "alpha" })), - on: ["zulu", "alpha"], - overrides: { agentDevice: { enabled: true } }, + modules: [ + ...definitions(fakeModule({ name: "zulu" })), + agentDeviceModuleWith({ upstream: new FakeAgentDevice() }), + ...definitions(fakeModule({ name: "alpha" })), + ], + on: ["zulu", "agent-device", "alpha"], }); expect((await capabilities(daemon)).map((entry) => entry.name)).toEqual([ - "agent-device", "zulu", + "agent-device", "alpha", ]); }); @@ -270,20 +272,21 @@ describe("optional modules: HTTP routes", () => { it("with agent-device on and a fake module registered, both modules' routes and relay targets answer, and both capabilities are listed", async () => { const seen: string[] = []; const { daemon, origin } = await start({ - extra: { agentDeviceUpstream: new FakeAgentDevice() }, http: true, - modules: definitions( - fakeModule({ - mount: () => handler, - name: "alpha", - relayTarget: (_request, tenant) => { - seen.push(tenant); - return Promise.resolve(new Response("alpha")); - }, - }), - ), - on: ["alpha"], - overrides: { agentDevice: { enabled: true } }, + modules: [ + agentDeviceModuleWith({ upstream: new FakeAgentDevice() }), + ...definitions( + fakeModule({ + mount: () => handler, + name: "alpha", + relayTarget: (_request, tenant) => { + seen.push(tenant); + return Promise.resolve(new Response("alpha")); + }, + }), + ), + ], + on: ["agent-device", "alpha"], }); const headers = await agentHeaders(daemon); const uplink = await openUplink(daemon); @@ -441,10 +444,10 @@ describe("optional modules: a start that cannot go on", () => { title: "two definitions with one name", }, { - bad: () => [fakeModule({ name: "agent-device" })], - good: () => [fakeModule({ name: "agent-devices" })], + bad: () => [fakeModule({ name: "agent-device" }), fakeModule({ name: "agent-device" })], + good: () => [fakeModule({ name: "agent-device" }), fakeModule({ name: "agent-devices" })], names: ["agent-device"], - title: "a definition named agent-device while it is wired by hand", + title: "two definitions named agent-device", }, { bad: () => [fakeModule({ name: "Bad_Name" })], @@ -453,10 +456,10 @@ describe("optional modules: a start that cannot go on", () => { title: "a name that fails the capability name check", }, { - bad: () => Array.from({ length: 16 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), - good: () => Array.from({ length: 15 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), - names: ["mod-15", "16"], - title: "16 fake modules plus agent-device wired by hand (17 capabilities)", + bad: () => Array.from({ length: 17 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), + good: () => Array.from({ length: 16 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), + names: ["mod-16", "17"], + title: "17 fake modules (17 capabilities)", }, { bad: () => [fakeModule({ loadError: new Error("no such file"), name: "alpha" })], @@ -787,33 +790,27 @@ describe("optional modules: stop hooks", () => { expect(messages.indexOf("drain done")).toBeLessThan(messages.indexOf("Daemon stopping")); }); - it("beforeDrain runs after agent-device's waiting allocates are cancelled through leasing", async () => { - const cancelledAtDrain: number[] = []; + it("by the time the stop ends, agent-device's waiting allocate is cancelled through leasing and another module's beforeDrain has run", async () => { + const drainRan: boolean[] = []; let directory = ""; const rig = await start({ - extra: { agentDeviceUpstream: new FakeAgentDevice() }, http: true, - modules: definitions( - fakeModule({ - hooks: { - beforeDrain: async () => { - const history = await readFile(join(directory, "events.jsonl"), "utf8"); - cancelledAtDrain.push( - history - .split("\n") - .filter( - (line) => line.includes('"lease.rejected"') && line.includes('"cancelled"'), - ).length, - ); + modules: [ + agentDeviceModuleWith({ upstream: new FakeAgentDevice() }), + ...definitions( + fakeModule({ + hooks: { + beforeDrain: async () => { + drainRan.push(true); + }, }, - }, - name: "alpha", - }), - ), - on: ["alpha"], + name: "alpha", + }), + ), + ], + on: ["agent-device", "alpha"], phones: true, overrides: { - agentDevice: { enabled: true }, capacity: { config: { maxRunning: 1 }, strategy: "fixed" }, }, }); @@ -846,7 +843,12 @@ describe("optional modules: stop hooks", () => { await rig.daemon.stop("test"); await allocate; - expect(cancelledAtDrain).toEqual([1]); + const history = await readFile(join(directory, "events.jsonl"), "utf8"); + const cancelled = history + .split("\n") + .filter((line) => line.includes('"lease.rejected"') && line.includes('"cancelled"')); + expect(drainRan).toEqual([true]); + expect(cancelled).toHaveLength(1); }); it("beforeDriversDispose finishes before any driver's dispose starts", async () => { diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 6fd271af..bdae7913 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -207,8 +207,9 @@ describe("DaemonServer", () => { [35, "cannot read a worker view's load"], [36, "cannot read the module-unavailable finding in doctor.run"], [37, "cannot read config.get's capabilities"], + [38, "cannot read agent-device's section under config.get's capabilities"], ])( - "answers PROTOCOL_VERSION_UNSUPPORTED, naming protocol 38, to a client on protocol %i, which %s", + "answers PROTOCOL_VERSION_UNSUPPORTED, naming protocol 39, to a client on protocol %i, which %s", async (version) => { const harness = await createHarness(); const previous = await createClient(harness.socketPath); @@ -218,7 +219,7 @@ describe("DaemonServer", () => { ).resolves.toMatchObject({ error: { code: "PROTOCOL_VERSION_UNSUPPORTED", - details: { client: { min: version, max: version }, daemon: { min: 38, max: 38 } }, + details: { client: { min: version, max: version }, daemon: { min: 39, max: 39 } }, }, ok: false, }); @@ -3573,7 +3574,6 @@ function testConfig( return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 10 * gibibyte }, gateway: { diff --git a/src/daemon/worker-capabilities.e2e.test.ts b/src/daemon/worker-capabilities.e2e.test.ts index 31cd214e..b4f5a350 100644 --- a/src/daemon/worker-capabilities.e2e.test.ts +++ b/src/daemon/worker-capabilities.e2e.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { FakeDriver } from "../core/testing.js"; -import { FakeAgentDevice } from "../agent-device/testing.js"; +import { agentDeviceModuleWith, FakeAgentDevice } from "../agent-device/testing.js"; import { FakeHostInfo, MemoryFilesystem, NoopLogger, SystemClock } from "../ports/index.js"; import type { DispatchSession } from "./dispatch.js"; import { startDaemon } from "./main.js"; @@ -51,6 +51,7 @@ describe("a worker's capabilities through a gateway", () => { filesystem: new MemoryFilesystem(), hostInfo: new FakeHostInfo({ arch: "x64", os: "Linux", osVersion: "6.8.0" }), logger: new NoopLogger(), + optionalModules: [agentDeviceModuleWith({})], statePath: join(gatewayDirectory, "state.json"), version: "1.0.0-e2e", }); @@ -63,9 +64,8 @@ describe("a worker's capabilities through a gateway", () => { const workerDirectory = await mkdtemp(join(tmpdir(), "simlock-e2e-caps-worker-")); directories.push(workerDirectory); const worker = await startDaemon({ - agentDeviceUpstream: options.fake, configOverrides: { - agentDevice: { enabled: options.enabled ?? true }, + capabilities: { "agent-device": { enabled: options.enabled ?? true } }, gateway: { label: "worker-a", token: secret, url: `ws://127.0.0.1:${String(port)}` }, }, dataDirectory: workerDirectory, @@ -77,6 +77,7 @@ describe("a worker's capabilities through a gateway", () => { platform: "android", }), ], + optionalModules: [agentDeviceModuleWith({ upstream: options.fake })], filesystem: new MemoryFilesystem(), logger: new NoopLogger(), statePath: join(workerDirectory, "state.json"), @@ -117,7 +118,7 @@ describe("a worker's capabilities through a gateway", () => { ]); }, 30_000); - it("the gateway's worker.list shows a worker with agentDevice.enabled false as off", async () => { + it("the gateway's worker.list shows a worker with capabilities.agent-device.enabled false as off", async () => { const { gateway } = await startFleet({ enabled: false, fake: new FakeAgentDevice() }); const { workers } = await gateway.dispatch("worker.list", {}, admin); diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index 73246f2b..04142e7f 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -29,7 +29,6 @@ import { import { WorkerRegistry } from "./worker-registry.js"; const gatewayConfig = { - agentDevice: { command: "agent-device", enabled: false }, capabilities: {}, capacity: { strategy: "fixed" as const, config: { maxRunning: 4 } }, diskPressure: { freeBytesThreshold: 1 }, diff --git a/src/gateway/service.test.ts b/src/gateway/service.test.ts index 0695dbf6..bef1fd6c 100644 --- a/src/gateway/service.test.ts +++ b/src/gateway/service.test.ts @@ -1234,7 +1234,7 @@ describe("GatewayService", () => { min: PROTOCOL_VERSION_RANGE.max - 1, max: PROTOCOL_VERSION_RANGE.max - 1, }; - expect(PROTOCOL_VERSION_RANGE.max).toBe(38); + expect(PROTOCOL_VERSION_RANGE.max).toBe(39); expect(negotiateProtocolVersion(PROTOCOL_VERSION_RANGE, previous)).toBeUndefined(); const harness = fleet(); await harness.service.start(); diff --git a/src/http/test-fakes.ts b/src/http/test-fakes.ts index b56fd182..236501a6 100644 --- a/src/http/test-fakes.ts +++ b/src/http/test-fakes.ts @@ -33,7 +33,6 @@ export function testConfig( return { capabilities: {}, mode, - agentDevice: { command: "agent-device", enabled: false }, gateway: { disconnectedRetentionMs: 24 * 60 * 60_000, execTimeoutMs: 11 * 60_000, diff --git a/src/leasing/acquisition-planner.test.ts b/src/leasing/acquisition-planner.test.ts index cf8d105c..fd59ffe8 100644 --- a/src/leasing/acquisition-planner.test.ts +++ b/src/leasing/acquisition-planner.test.ts @@ -17,7 +17,6 @@ const spec = { physical: false, model: "iPhone 16", osVersion: "26.5", platform: const config: Config = { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 10 * gibibyte }, gateway: { diff --git a/src/leasing/create-leasing.test.ts b/src/leasing/create-leasing.test.ts index 2e83e7a6..399f89cc 100644 --- a/src/leasing/create-leasing.test.ts +++ b/src/leasing/create-leasing.test.ts @@ -46,7 +46,6 @@ function config( return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 10 * gibibyte }, gateway: { diff --git a/src/leasing/lease-acquisition-coordinator.test.ts b/src/leasing/lease-acquisition-coordinator.test.ts index 91d3848b..9311582d 100644 --- a/src/leasing/lease-acquisition-coordinator.test.ts +++ b/src/leasing/lease-acquisition-coordinator.test.ts @@ -64,7 +64,6 @@ function config(maxDevices = 1, maxRunning = 1): Config { return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 10 * gibibyte }, gateway: { diff --git a/src/leasing/lease-end-participants.test.ts b/src/leasing/lease-end-participants.test.ts index e0072ce2..8836d57a 100644 --- a/src/leasing/lease-end-participants.test.ts +++ b/src/leasing/lease-end-participants.test.ts @@ -113,7 +113,6 @@ function config(options: EngineOptions): Config { return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 10 * gibibyte }, gateway: { diff --git a/src/leasing/lease-health-monitor.test.ts b/src/leasing/lease-health-monitor.test.ts index df3dba27..8535807b 100644 --- a/src/leasing/lease-health-monitor.test.ts +++ b/src/leasing/lease-health-monitor.test.ts @@ -25,7 +25,6 @@ function config(overrides: Partial = {}): Config { return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 10 * gibibyte }, gateway: { diff --git a/src/leasing/physical-device-watcher.test.ts b/src/leasing/physical-device-watcher.test.ts index ddaa2548..69b48253 100644 --- a/src/leasing/physical-device-watcher.test.ts +++ b/src/leasing/physical-device-watcher.test.ts @@ -47,7 +47,6 @@ function config(options: HarnessOptions): Config { return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 10 * gibibyte }, gateway: { diff --git a/src/leasing/physical-leasing.test.ts b/src/leasing/physical-leasing.test.ts index 53568727..4aec8f20 100644 --- a/src/leasing/physical-leasing.test.ts +++ b/src/leasing/physical-leasing.test.ts @@ -35,7 +35,6 @@ function config(leaseOverrides: object = {}): Config { return { capabilities: {}, mode: "worker", - agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diskPressure: { freeBytesThreshold: 10 * gibibyte }, gateway: { diff --git a/src/lint-rules.test.ts b/src/lint-rules.test.ts index c976ba4e..504729af 100644 --- a/src/lint-rules.test.ts +++ b/src/lint-rules.test.ts @@ -576,6 +576,65 @@ describe("a module's definition.ts, read by the registry without loading the mod }); }); +describe("main.ts loads optional modules lazily", () => { + function mainViolations(source: string): string[] { + const dir = join(scratch, `main-lazy-${counter++}`); + const world: Record = { + "src/agent-device/index.ts": "export const value = 1;\nexport type Shape = number;\n", + "src/daemon/main.ts": source, + }; + for (const [path, contents] of Object.entries(world)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), contents); + } + copyFileSync(join(repoRoot, ".dependency-cruiser.mjs"), join(dir, ".dependency-cruiser.mjs")); + copyFileSync(join(repoRoot, "tsconfig.json"), join(dir, "tsconfig.json")); + return cruise(dir, ["src"]) + .filter((v) => v.from === "src/daemon/main.ts") + .map((v) => v.rule.name); + } + + it.each([ + { + kind: "a value import", + source: 'import { value } from "../agent-device/index.js";\nexport { value };\n', + verdict: "fails", + }, + { + kind: "a re-export", + source: 'export { value } from "../agent-device/index.js";\n', + verdict: "fails", + }, + { + kind: "a bare import", + source: 'import "../agent-device/index.js";\nexport {};\n', + verdict: "fails", + }, + { + kind: "a dynamic import", + source: 'export const load = () => import("../agent-device/index.js");\n', + verdict: "passes", + }, + { + kind: "a type-only import", + source: 'import type { Shape } from "../agent-device/index.js";\nexport type S = Shape;\n', + verdict: "passes", + }, + { + kind: "a type import", + source: 'export type S = import("../agent-device/index.js").Shape;\n', + verdict: "passes", + }, + ])( + "dependency-cruiser $verdict $kind of src/agent-device from main.ts", + ({ source, verdict }) => { + expect(mainViolations(source).includes("main-loads-modules-lazily")).toBe( + verdict === "fails", + ); + }, + ); +}); + describe("the cycle check between components", () => { function cycleWorld(files: Record): Violation[] { const dir = join(scratch, `cycles-${counter++}`); @@ -624,6 +683,7 @@ describe("the repo's known violations", () => { it("never grows past what the repo had when dependency-cruiser was adopted", () => { expect(read(".dependency-cruiser-known-violations.json").length).toBeLessThanOrEqual(24); - expect(read(".dependency-cruiser-known-cycles.json").length).toBeLessThanOrEqual(6); + // why: two of the eight are the registry joining the existing daemon, http, agent-device cycle; they go when agent-device stops importing http. + expect(read(".dependency-cruiser-known-cycles.json").length).toBeLessThanOrEqual(8); }); }); diff --git a/src/module-registry/AGENTS.md b/src/module-registry/AGENTS.md index 660882f0..b2651830 100644 --- a/src/module-registry/AGENTS.md +++ b/src/module-registry/AGENTS.md @@ -1,6 +1,6 @@ # Module registry -Does: lists the optional modules (`optionalModules`), empty until a module is added. +Does: lists the optional modules (`optionalModules`), agent-device is its first. Entry: `index.ts` → `optionalModules`. Uses: `src/modules` types. A module's entry here is its definition file, which loads no module code (dependency rule `definition-is-light`). Imported by: `daemon/main.ts` and `cli/index.ts`, for each module's config. diff --git a/src/module-registry/index.ts b/src/module-registry/index.ts index 66dbbee9..dff7575b 100644 --- a/src/module-registry/index.ts +++ b/src/module-registry/index.ts @@ -1,3 +1,6 @@ +import { agentDeviceModule } from "../agent-device/definition.js"; import type { OptionalModuleDefinition } from "../modules/index.js"; -export const optionalModules: readonly OptionalModuleDefinition[] = []; +export const optionalModules: readonly OptionalModuleDefinition[] = [ + agentDeviceModule as OptionalModuleDefinition, +]; diff --git a/src/modules/AGENTS.md b/src/modules/AGENTS.md index bbe6c1f0..76939641 100644 --- a/src/modules/AGENTS.md +++ b/src/modules/AGENTS.md @@ -16,7 +16,7 @@ Imported by: `daemon/main.ts` (the factory and types). Core, leasing, drivers, g ## Rules - A module is on only under `capabilities..enabled`; off, nothing of it is loaded and its capability reads `off`. -- Names: the capability name pattern, unique (also against `wiredElsewhere`), not a core route segment, at most 16 capabilities. +- Names: the capability name pattern, unique, not a core route segment, at most 16 capabilities. - Creating starts nothing. A start that fails before the socket is claimed drops created modules; after it, the normal stop runs their stop hooks. - Hooks are direct calls. `afterSocketClaimed` is not awaited; `beforeDrain` and `beforeDriversDispose` share one 30 s budget per step; a throw is logged and the step goes on. - Each by-shape type (`Module*`) has a type test in `src/daemon/module-shapes.test.ts` against its original. diff --git a/src/modules/conformance.test.ts b/src/modules/conformance.test.ts index 25276118..6cf2bfe9 100644 --- a/src/modules/conformance.test.ts +++ b/src/modules/conformance.test.ts @@ -67,7 +67,6 @@ describe("every optional module's contract", () => { definitions: [definition], logger: new NoopLogger(), role: "worker", - wiredElsewhere: [], }); expect(loadWorker).not.toHaveBeenCalled(); @@ -124,3 +123,9 @@ describe("every optional module's contract", () => { }, ); }); + +describe("the registry", () => { + it("registers agent-device, so the conformance suite above runs over it", () => { + expect(subjects.map(([name]) => name)).toContain("agent-device"); + }); +}); diff --git a/src/modules/host.ts b/src/modules/host.ts index b48bf8c2..e620fa5b 100644 --- a/src/modules/host.ts +++ b/src/modules/host.ts @@ -26,11 +26,8 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function checkNames( - definitions: readonly OptionalModuleDefinition[], - wiredElsewhere: readonly string[], -): void { - const seen = new Set(wiredElsewhere); +function checkNames(definitions: readonly OptionalModuleDefinition[]): void { + const seen = new Set(); for (const { name } of definitions) { if (!capabilityNameSchema.safeParse(name).success) { throw new Error(`Optional module "${name}": the name is not a valid capability name`); @@ -43,7 +40,7 @@ function checkNames( throw new Error(`Optional module "${name}": the name is reserved for a core route`); } } - const total = definitions.length + wiredElsewhere.length; + const total = definitions.length; if (total > MAX_CAPABILITIES) { const last = definitions[definitions.length - 1]?.name ?? ""; throw new Error( @@ -97,8 +94,8 @@ async function runHook( } export async function createModuleHost(options: ModuleHostOptions): Promise { - const { definitions, logger, wiredElsewhere } = options; - checkNames(definitions, wiredElsewhere); + const { definitions, logger } = options; + checkNames(definitions); const capabilitySources: ModuleCapabilitySource[] = []; const started: Started[] = []; diff --git a/src/modules/index.ts b/src/modules/index.ts index 21ab2882..2bf0032a 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,6 +1,6 @@ -// fallow-ignore-next-line unused-type -- part of the contract a module imports from this index; no module is registered yet. +// fallow-ignore-next-line unused-type -- part of the contract a module imports from this index; no registered module names it yet. export type { ModuleConfig } from "./types.js"; -// fallow-ignore-next-line unused-type -- part of the contract a module imports from this index; no module is registered yet. +// fallow-ignore-next-line unused-type -- part of the contract a module imports from this index; no registered module names it yet. export type { MountDeps } from "./types.js"; export type { ModuleCapabilitySource, diff --git a/src/modules/types.ts b/src/modules/types.ts index 50a8cf2f..8840ffff 100644 --- a/src/modules/types.ts +++ b/src/modules/types.ts @@ -141,5 +141,4 @@ export interface ModuleHostOptions { readonly config: Config; readonly logger: Logger; readonly context: Omit, "name" | "config" | "daemonConfig" | "logger">; - readonly wiredElsewhere: readonly string[]; }