From 32bef16e64cc0e9a9d6ebad353cc3658fb3f17dc Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 21 Aug 2026 19:01:01 +0200 Subject: [PATCH 1/3] chore: trim npm tarball and fix missing CLI shebang Publish only dist (minus tests and dist/e2e) plus README/LICENSE via the files field, instead of the whole repo. Also add the missing #!/usr/bin/env node shebang to cli/main.ts, without which the installed bin was not directly executable. Claude-Session: https://claude.ai/code/session_01DFg6QoHAJq5sLXm2vDwcxW --- package.json | 8 +++++++- src/cli/main.ts | 1 + tsconfig.build.json | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tsconfig.build.json diff --git a/package.json b/package.json index d551e2f..74e7753 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,15 @@ "bin": { "simlock": "dist/cli/main.js" }, + "files": [ + "dist", + "!dist/e2e", + "!dist/**/*.test.js", + "!dist/**/*.test.d.ts" + ], "type": "module", "scripts": { - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "build:e2e": "tsc -p e2e/fake-driver/tsconfig.json", "test": "vitest run --project unit", "test:e2e": "pnpm run build && pnpm run build:e2e && vitest run --project e2e --tags-filter='!slow'", diff --git a/src/cli/main.ts b/src/cli/main.ts index 278ebd4..3f29eb8 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env node import { runCli } from "./index.js"; process.exitCode = await runCli(process.argv.slice(2)); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..0a11719 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts"] +} From fd7edad63dca5216f88318784630064cf53186e4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 21 Aug 2026 19:01:16 +0200 Subject: [PATCH 2/3] chore: release v0.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 74e7753..512a82e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simlock", - "version": "0.1.0", + "version": "0.2.0", "description": "Control plane for iOS simulators and Android emulators.", "keywords": [ "agents", From 67255ca3f7d363bccca6515eab5fd01fdbc6caa6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 31 Aug 2026 12:53:36 +0200 Subject: [PATCH 3/3] feat(capacity): make the concurrency policy a pluggable strategy How many devices may exist and run at once was decided in one place that hardcoded a single policy: device limits plus a RAM budget, with the limits themselves derived from CPU and RAM. Pinning a plain number was possible only by setting four coordinated keys, and the RAM gate still sat underneath and could refuse below the pinned number. Capacity is now a `CapacityStrategy` behind one interface. Each strategy lives in its own directory with a single entry point and is registered in one map, from which the config type, its validation, and its defaults are all derived -- so adding a policy touches neither `CapacityCoordinator` nor its callers. Two ship: - `resource` -- today's behaviour, unchanged, still the default. - `fixed` -- a pinned number with no machine inspection at all. `maxRunning` alone is a complete configuration. Config gains a `capacity` namespace discriminated on `strategy`, with the strategy's own options under `capacity.config`. The pre-existing top-level `limits` and `ramBudget` keys are normalized into it per layer, before merging, so layer precedence is unaffected by which spelling each layer uses. Existing config files keep working silently and unchanged; the e2e lane deliberately stays on the old spelling to cover that path end to end. Closes #63 Claude-Session: https://claude.ai/code/session_015M1UR25DZcAxQeYUuEhLZ3 --- docs/ARCHITECTURE.md | 16 +- docs/CLI.md | 9 +- docs/CONFIGURATION.md | 79 +++- e2e/helpers/env.ts | 10 +- src/cli/index.test.ts | 15 +- src/core/acquisition-planner.test.ts | 31 +- src/core/acquisition-planner.ts | 2 +- src/core/capacity.test.ts | 127 ------- src/core/capacity.ts | 114 ------ src/core/capacity/contract.test.ts | 86 +++++ .../coordinator.test.ts} | 65 ++-- .../coordinator.ts} | 69 ++-- src/core/capacity/index.ts | 20 + src/core/capacity/limits.ts | 72 ++++ .../capacity/strategies/fixed/index.test.ts | 88 +++++ src/core/capacity/strategies/fixed/index.ts | 101 +++++ src/core/capacity/strategies/index.ts | 67 ++++ .../strategies/resource/index.test.ts | 138 +++++++ .../capacity/strategies/resource/index.ts | 133 +++++++ src/core/capacity/strategy.ts | 85 +++++ src/core/cleanup/idle-destroy.test.ts | 15 +- src/core/cleanup/idle-shutdown.test.ts | 15 +- src/core/config.test.ts | 183 ++++++++- src/core/config.ts | 355 +++++++++--------- src/core/device-provisioner.test.ts | 2 +- src/core/device-provisioner.ts | 2 +- src/core/doctor.test.ts | 15 +- src/core/index.ts | 6 - .../lease-acquisition-coordinator.test.ts | 27 +- src/core/lease-acquisition-coordinator.ts | 2 +- src/core/lease-engine.test.ts | 34 +- src/core/lease-engine.ts | 14 +- src/core/lease-health-monitor.test.ts | 15 +- src/core/lease-ports.ts | 4 +- src/core/nuke.test.ts | 15 +- src/core/reaper.test.ts | 15 +- src/core/startup-converger.test.ts | 3 +- src/core/validation.ts | 104 +++++ src/core/warm-pool-coordinator.test.ts | 31 +- src/core/warm-pool-coordinator.ts | 2 +- src/daemon/server.test.ts | 15 +- src/daemon/server.ts | 2 +- 42 files changed, 1579 insertions(+), 624 deletions(-) delete mode 100644 src/core/capacity.test.ts delete mode 100644 src/core/capacity.ts create mode 100644 src/core/capacity/contract.test.ts rename src/core/{capacity-coordinator.test.ts => capacity/coordinator.test.ts} (63%) rename src/core/{capacity-coordinator.ts => capacity/coordinator.ts} (62%) create mode 100644 src/core/capacity/index.ts create mode 100644 src/core/capacity/limits.ts create mode 100644 src/core/capacity/strategies/fixed/index.test.ts create mode 100644 src/core/capacity/strategies/fixed/index.ts create mode 100644 src/core/capacity/strategies/index.ts create mode 100644 src/core/capacity/strategies/resource/index.test.ts create mode 100644 src/core/capacity/strategies/resource/index.ts create mode 100644 src/core/capacity/strategy.ts create mode 100644 src/core/validation.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f60bbe..14831ce 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,8 +61,9 @@ MCP client ──spawns──> stdio MCP ┘ ## Core vs. drivers The core is platform-agnostic and written once: lease table, fair wait queue, -managed-device registry, device-limit and RAM capacity accounting (RAM is the -binding constraint for Android emulators), the device state machine, the +managed-device registry, capacity accounting behind a pluggable strategy +(the default derives limits from the machine and treats RAM as the binding +constraint for Android emulators), the device state machine, the cleanup reaper, the leased-device health monitor, the event bus, and warm-pool *policy*. @@ -85,7 +86,12 @@ devices) must require **no core changes**. If it does, the interface leaked. ## Running capacity Managed-device limits govern provisioning, while running limits govern any -operation that starts a device. The core accounts `ready`, `leased`, +operation that starts a device. Where those limits come from is a +`CapacityStrategy`, selected by config: `resource` derives them from the +machine and adds a RAM budget, `fixed` pins them to a configured number. +Each strategy lives behind one entry point in `core/capacity/strategies/` +and is registered in one map, so adding a policy touches neither the +coordinator nor its callers. The core accounts `ready`, `leased`, `reclaiming`, and `quarantined` devices as running. A serialized, platform-agnostic reservation covers provisioning and boots from `shutdown` until the registry commits the resulting running or non-running state. Global @@ -301,8 +307,8 @@ capacity coordinator into these direct transactional call chains: A release passes its committed result directly to `WarmPoolCoordinator`, which performs reclaim and warm-pool disposition — without the releasing caller waiting on it (see "Release hands the purge off"). -- `CapacityCoordinator` owns provisioning and running reservations while pure - capacity functions calculate limits. `DeviceOperationClaims` excludes +- `CapacityCoordinator` owns provisioning and running reservations while the + configured `CapacityStrategy` decides the limits. `DeviceOperationClaims` excludes overlapping boot, eviction, cleanup, and nuke operations per device. - `CleanupReaper` evaluates pure rules and directly calls `CleanupActionExecutor`; the executor revalidates registry ownership, diff --git a/docs/CLI.md b/docs/CLI.md index f160035..4be9040 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -329,9 +329,12 @@ shows the current file with the immediately preceding one prepended. Show the effective configuration (defaults + config file + overrides): managed and running capacity limits, idle tiers T1/T2/T3, TTLs, disk-pressure threshold, and the daemon's log level/rotation cap (`log.level`, -`log.rotateBytes`). With no args, prints everything. Running capacity -uses `limits.maxRunning` globally and `limits..maxRunning` for each -driver; both must have room before provisioning or booting a shutdown device. +`log.rotateBytes`). With no args, prints everything. The capacity numbers +come from the selected capacity strategy (`capacity.strategy`, configured +under `capacity.config` — see +[CONFIGURATION.md](CONFIGURATION.md#capacity-strategies)). Whichever strategy +is running, both a global and a per-platform running limit must have room +before Simlock provisions or boots a shutdown device. ## Environment variables diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 984af85..39665ad 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -7,13 +7,7 @@ a warning. Inspect the effective, merged configuration at any time with | Property | Description | Default | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| `limits.maxRunning` | Global cap on devices running at once, across both platforms. | Sum of `limits.ios.maxDevices` and `limits.android.maxDevices` | -| `limits.ios.maxDevices` | Max number of iOS simulators Simlock will manage at once. | `max(1, cpuCount / 2)` | -| `limits.ios.maxRunning` | Max number of iOS simulators running at once. | Same as `limits.ios.maxDevices` | -| `limits.android.maxDevices` | Max number of Android emulators Simlock will manage at once. | `max(1, min(cpuCount / 4, totalRamGb / 8))` | -| `limits.android.maxRunning` | Max number of Android emulators running at once. | Same as `limits.android.maxDevices` | -| `ramBudget.iosBytesPerDevice` | RAM reserved per iOS simulator when computing capacity. | `1.5 GiB` | -| `ramBudget.androidBytesPerDevice` | RAM reserved per Android emulator when computing capacity. | `4 GiB` | +| `capacity.strategy` | Which policy decides how many devices may exist and run at once: `resource` or `fixed`. The options under `capacity.config` are that strategy's own -- see [Capacity strategies](#capacity-strategies). | `resource` | | `idle.shutdownAfterMs` | How long an unused device sits idle before Simlock shuts it down (tier 1, reclaims RAM). | `10 minutes` | | `idle.deleteAfterMs` | How long a shut-down device sits idle before Simlock deletes it (tier 2, reclaims disk). | `1 hour` | | `warmPool.quarantine.maxRetries` | Failed purge retries allowed on a quarantined device (after the triggering failure) before Simlock gives up and destroys it. | `3` | @@ -42,19 +36,78 @@ must be non-negative numbers (milliseconds and bytes, respectively). `health.maxConcurrentRecoveries` must be positive integers. `stalledTransition.thresholdMultiplier` must be a number `>= 1`; `stalledTransition.minimumThresholdMs` must be a non-negative number. +See [CLI.md](CLI.md#simlock-config-get-keyset-key-value) for the +`simlock config` command itself. + +## Capacity strategies + +How many devices Simlock lets exist and run at once is decided by a capacity +strategy. `capacity.strategy` picks one; `capacity.config` holds that +strategy's own options, so its shape depends on the strategy you selected. + +### `resource` (default) + +Device and running ceilings derived from the machine, with a RAM budget on +top: a device is only created if its budgeted RAM still fits under the +machine's total, minus 4 GiB left for the OS. + +| Property | Description | Default | +| ----------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `capacity.config.limits.maxRunning` | Global cap on devices running at once, across both platforms. | Sum of the two `maxDevices` values | +| `capacity.config.limits.ios.maxDevices` | Max iOS simulators Simlock will manage at once. | `max(1, cpuCount / 2)` | +| `capacity.config.limits.ios.maxRunning` | Max iOS simulators running at once. | Same as `capacity.config.limits.ios.maxDevices` | +| `capacity.config.limits.android.maxDevices` | Max Android emulators Simlock will manage at once. | `max(1, min(cpuCount / 4, totalRamGb / 8))` | +| `capacity.config.limits.android.maxRunning` | Max Android emulators running at once. | Same as `capacity.config.limits.android.maxDevices` | +| `capacity.config.ramBudget.iosBytesPerDevice` | RAM reserved per iOS simulator when computing capacity. | `1.5 GiB` | +| `capacity.config.ramBudget.androidBytesPerDevice` | RAM reserved per Android emulator when computing capacity. | `4 GiB` | + Running limits are independent of managed-device limits — an omitted `maxRunning` defaults to its corresponding `maxDevices` value (and, at the global level, to their sum): ```json { - "limits": { - "maxRunning": 3, - "ios": { "maxDevices": 4, "maxRunning": 2 }, - "android": { "maxDevices": 2, "maxRunning": 2 } + "capacity": { + "strategy": "resource", + "config": { + "limits": { + "maxRunning": 3, + "ios": { "maxDevices": 4, "maxRunning": 2 }, + "android": { "maxDevices": 2, "maxRunning": 2 } + } + } } } ``` -See [CLI.md](CLI.md#simlock-config-get-keyset-key-value) for the -`simlock config` command itself. +### `fixed` + +A pinned number of devices, with no machine inspection at all: no RAM +budget, and no CPU- or RAM-derived defaults. Use it when you want the +concurrency to be exactly the number you wrote down, on every machine. + +| Property | Description | Default | +| ------------------------------------- | ---------------------------------------------------------- | ------------------------------------------ | +| `capacity.config.maxRunning` | Devices running at once, across both platforms. | `2` | +| `capacity.config.ios.maxRunning` | iOS simulators running at once. | `capacity.config.maxRunning` | +| `capacity.config.ios.maxDevices` | iOS simulators Simlock will manage at once. | `capacity.config.ios.maxRunning` | +| `capacity.config.android.maxRunning` | Android emulators running at once. | `capacity.config.maxRunning` | +| `capacity.config.android.maxDevices` | Android emulators Simlock will manage at once. | `capacity.config.android.maxRunning` | + +`maxRunning` on its own is a complete configuration — the per-platform +blocks exist only to carve that budget up: + +```json +{ + "capacity": { "strategy": "fixed", "config": { "maxRunning": 4 } } +} +``` + +### Older config files + +Before capacity strategies existed, the `resource` options were spelled as +top-level `limits` and `ramBudget` keys. Those still work exactly as they +did — a config file written against an older Simlock keeps its behaviour +without changes, and needs none. Setting them alongside an explicitly +selected non-`resource` strategy is the one case Simlock warns about, since +those settings would have no effect. diff --git a/e2e/helpers/env.ts b/e2e/helpers/env.ts index 3c3f036..5a4e3e7 100644 --- a/e2e/helpers/env.ts +++ b/e2e/helpers/env.ts @@ -68,9 +68,9 @@ function errorMessage(error: unknown): string { * Capacity inputs the fake-driver lane pins so a flow's device budget comes from the * flow, never from the machine running it. `defaultConfig` derives the per-platform * device limits from `availableParallelism()` (a 2-core runner yields exactly one iOS - * device), and `capacity.ts` independently gates on `totalmem()` minus a 4 GiB OS - * reserve at 1.5 GiB per iOS device (a 7 GiB runner therefore admits two, whatever - * `limits` says). A flow needing more concurrent devices than that passes on a dev + * device), and the `resource` capacity strategy independently gates on `totalmem()` + * minus a 4 GiB OS reserve at 1.5 GiB per iOS device (a 7 GiB runner therefore admits + * two, whatever `limits` says). A flow needing more concurrent devices than that passes on a dev * machine and wedges on a small CI runner -- the extra lease simply queues until the * test's own timeout, which reads as a hang rather than as "capacity refused". * @@ -79,6 +79,10 @@ function errorMessage(error: unknown): string { * wired. Flows that exercise capacity itself override `limits` on top of this. The real * -SDK lane deliberately does not get this treatment: there the host's RAM is a real * constraint and the production defaults are what should apply. + * + * These are deliberately written in the pre-`capacity.strategy` spelling: the whole e2e + * lane then doubles as end-to-end coverage that a config file written against an older + * Simlock still configures the `resource` strategy correctly. */ const FAKE_LANE_BASE_CONFIG: Record = { limits: { diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 3071c3a..07ad286 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1048,13 +1048,18 @@ function testConfig(): Config { stalledTransition: { thresholdMultiplier: 3, minimumThresholdMs: 60_000 }, idle: { deleteAfterMs: 60_000, shutdownAfterMs: 10_000 }, lease: { detachedTtlMs: 60_000, heldTtlBackstopMs: 60_000, heartbeatIntervalMs: 15_000 }, - limits: { - android: { maxDevices: 1, maxRunning: 1 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 1 + 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 1, maxRunning: 1 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 1 + 1, + }, + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, + }, }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, warmPool: { quarantine: { maxRetries: 3, diff --git a/src/core/acquisition-planner.test.ts b/src/core/acquisition-planner.test.ts index 372ba43..9eecea8 100644 --- a/src/core/acquisition-planner.test.ts +++ b/src/core/acquisition-planner.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { FakeSystemStats } from "../ports/index.js"; import { AcquisitionPlanner } from "./acquisition-planner.js"; -import { CapacityCoordinator } from "./capacity-coordinator.js"; +import { CapacityCoordinator, createCapacityStrategy } from "./capacity/index.js"; import type { Config } from "./config.js"; import { DeviceOperationClaims } from "./device-operation-claims.js"; import type { DeviceRecord, DeviceSpec, LeaseRecord } from "./domain.js"; @@ -31,12 +31,17 @@ const config: Config = { }, }, lease: { detachedTtlMs: 100, heldTtlBackstopMs: 100, heartbeatIntervalMs: 25 }, - limits: { - android: { maxDevices: 2, maxRunning: 2 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 2, maxRunning: 2 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 1, + }, + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, + }, }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, }; @@ -58,12 +63,14 @@ function device( function planner() { const claims = new DeviceOperationClaims(); const capacity = new CapacityCoordinator( - config, - new FakeSystemStats({ - cpuCount: 8, - freeRamBytes: 32 * gibibyte, - totalRamBytes: 32 * gibibyte, - }), + createCapacityStrategy( + config.capacity, + new FakeSystemStats({ + cpuCount: 8, + freeRamBytes: 32 * gibibyte, + totalRamBytes: 32 * gibibyte, + }), + ), ); return { claims, planner: new AcquisitionPlanner(capacity, claims) }; } diff --git a/src/core/acquisition-planner.ts b/src/core/acquisition-planner.ts index 07a9930..6696a6c 100644 --- a/src/core/acquisition-planner.ts +++ b/src/core/acquisition-planner.ts @@ -1,4 +1,4 @@ -import type { CapacityReservation, CapacityCoordinator } from "./capacity-coordinator.js"; +import type { CapacityReservation, CapacityCoordinator } from "./capacity/index.js"; import type { DeviceOperationClaim, DeviceOperationClaims } from "./device-operation-claims.js"; import { type DeviceRecord, diff --git a/src/core/capacity.test.ts b/src/core/capacity.test.ts deleted file mode 100644 index 0e86ac8..0000000 --- a/src/core/capacity.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { FakeSystemStats } from "../ports/index.js"; -import { - canProvision, - canReserveRunning, - type Config, - type CapacityDevice, - runningCapacity, -} from "./index.js"; - -const gibibyte = 1024 ** 3; - -const config: Config = { - diskPressure: { freeBytesThreshold: 10 * gibibyte }, - eventBuffer: { capacity: 1000 }, - health: { - enabled: true, - maxConcurrentRecoveries: 1, - maxRecoveryAttempts: 3, - probeIntervalMs: 30_000, - recoveryBackoffMs: 5_000, - stableObservations: 2, - }, - stalledTransition: { thresholdMultiplier: 3, minimumThresholdMs: 60_000 }, - idle: { deleteAfterMs: 60 * 60_000, shutdownAfterMs: 10 * 60_000 }, - warmPool: { - quarantine: { - maxRetries: 3, - maxRetryBackoffMs: 300_000, - retryBackoffMs: 30_000, - retryBackoffMultiplier: 2, - }, - }, - lease: { - detachedTtlMs: 15 * 60_000, - heldTtlBackstopMs: 60 * 60_000, - heartbeatIntervalMs: 5 * 60_000, - }, - limits: { - android: { maxDevices: 4, maxRunning: 2 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 2, - }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: 1.5 * gibibyte }, - log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, -}; - -function withStats(totalRamBytes: number): FakeSystemStats { - return new FakeSystemStats({ cpuCount: 8, freeRamBytes: totalRamBytes, totalRamBytes }); -} - -describe("canProvision", () => { - it("refuses provisioning at the platform device limit", () => { - const devices: CapacityDevice[] = [{ platform: "ios", state: "ready" }]; - - expect(canProvision("ios", devices, config, withStats(32 * gibibyte))).toEqual({ - ok: false, - reason: "device-limit", - }); - }); - - it("refuses provisioning when another device would exceed the RAM budget", () => { - const devices: CapacityDevice[] = [{ platform: "android", state: "ready" }]; - - expect(canProvision("android", devices, config, withStats(11 * gibibyte))).toEqual({ - ok: false, - reason: "ram-budget", - }); - }); - - it("does not count deleted devices against capacity", () => { - const devices: CapacityDevice[] = [{ platform: "ios", state: "deleted" }]; - - expect(canProvision("ios", devices, config, withStats(32 * gibibyte))).toEqual({ ok: true }); - }); - - it("accounts for active devices on both platforms in the shared RAM budget", () => { - const devices: CapacityDevice[] = [{ platform: "android", state: "ready" }]; - - expect(canProvision("ios", devices, config, withStats(9 * gibibyte))).toEqual({ - ok: false, - reason: "ram-budget", - }); - }); - - it("permits provisioning when allocation exactly reaches the RAM budget", () => { - const devices: CapacityDevice[] = [{ platform: "android", state: "ready" }]; - - expect(canProvision("ios", devices, config, withStats(9.5 * gibibyte))).toEqual({ ok: true }); - }); -}); - -describe("running capacity", () => { - it("counts only running lifecycle states and reservations", () => { - expect( - runningCapacity( - [ - { platform: "ios", state: "ready" }, - { platform: "android", state: "shutdown" }, - { platform: "android", state: "deleted" }, - ], - ["android"], - config, - ), - ).toEqual({ - global: { maxRunning: 2, overLimit: false, reserved: 1, running: 1 }, - ios: { maxRunning: 1, overLimit: false, reserved: 0, running: 1 }, - android: { maxRunning: 2, overLimit: false, reserved: 1, running: 0 }, - }); - }); - - it("requires both global and platform room", () => { - const globallyFull = [ - { platform: "ios", state: "ready" }, - { platform: "android", state: "ready" }, - ] as const; - expect(canReserveRunning("android", globallyFull, [], config)).toEqual({ - ok: false, - reason: "global-running-limit", - }); - expect(canReserveRunning("ios", [{ platform: "ios", state: "leased" }], [], config)).toEqual({ - ok: false, - reason: "platform-running-limit", - }); - }); -}); diff --git a/src/core/capacity.ts b/src/core/capacity.ts deleted file mode 100644 index f358999..0000000 --- a/src/core/capacity.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { SystemStats } from "../ports/index.js"; -import type { Config } from "./config.js"; - -const OS_RAM_RESERVE_BYTES = 4 * 1024 ** 3; - -export type CapacityPlatform = "ios" | "android"; - -export interface CapacityDevice { - readonly platform: CapacityPlatform; - readonly state: string; -} - -export type CapacityDecision = { readonly ok: true } | CapacityRefusal; - -interface CapacityRefusal { - readonly ok: false; - readonly reason: - | "device-limit" - | "ram-budget" - | "global-running-limit" - | "platform-running-limit"; -} - -export interface RunningCapacityEntry { - readonly running: number; - readonly maxRunning: number; - readonly reserved: number; - readonly overLimit: boolean; -} - -export interface RunningCapacity { - readonly global: RunningCapacityEntry; - readonly ios: RunningCapacityEntry; - readonly android: RunningCapacityEntry; -} - -const RUNNING_STATES = new Set(["ready", "leased", "reclaiming", "quarantined"]); - -export function runningCapacity( - devices: readonly CapacityDevice[], - reservations: readonly CapacityPlatform[], - config: Config, -): RunningCapacity { - const entry = (platform?: CapacityPlatform): RunningCapacityEntry => { - const running = devices.filter( - (device) => - RUNNING_STATES.has(device.state) && - (platform === undefined || device.platform === platform), - ).length; - const reserved = reservations.filter( - (reservation) => platform === undefined || reservation === platform, - ).length; - const maxRunning = - platform === undefined ? config.limits.maxRunning : config.limits[platform].maxRunning; - return { maxRunning, overLimit: running + reserved > maxRunning, reserved, running }; - }; - return { android: entry("android"), global: entry(), ios: entry("ios") }; -} - -export function canReserveRunning( - platform: CapacityPlatform, - devices: readonly CapacityDevice[], - reservations: readonly CapacityPlatform[], - config: Config, -): CapacityDecision { - const capacity = runningCapacity(devices, reservations, config); - const globalUsed = capacity.global.running + capacity.global.reserved; - if (globalUsed >= capacity.global.maxRunning) { - return { ok: false, reason: "global-running-limit" }; - } - const platformCapacity = capacity[platform]; - if (platformCapacity.running + platformCapacity.reserved >= platformCapacity.maxRunning) { - return { ok: false, reason: "platform-running-limit" }; - } - return { ok: true }; -} - -export function canProvision( - platform: CapacityPlatform, - devices: readonly CapacityDevice[], - config: Config, - systemStats: SystemStats, -): CapacityDecision { - const activeDevices = devices.filter((device) => device.state !== "deleted"); - - if ( - activeDevices.filter((device) => device.platform === platform).length >= - maxDevices(platform, config) - ) { - return { ok: false, reason: "device-limit" }; - } - - const usedRamBytes = activeDevices.reduce( - (total, device) => total + ramBudget(device.platform, config), - 0, - ); - const availableRamBytes = systemStats.totalRamBytes() - OS_RAM_RESERVE_BYTES; - - if (usedRamBytes + ramBudget(platform, config) > availableRamBytes) { - return { ok: false, reason: "ram-budget" }; - } - - return { ok: true }; -} - -function maxDevices(platform: CapacityPlatform, config: Config): number { - return config.limits[platform].maxDevices; -} - -function ramBudget(platform: CapacityPlatform, config: Config): number { - return platform === "ios" - ? config.ramBudget.iosBytesPerDevice - : config.ramBudget.androidBytesPerDevice; -} diff --git a/src/core/capacity/contract.test.ts b/src/core/capacity/contract.test.ts new file mode 100644 index 0000000..b88023b --- /dev/null +++ b/src/core/capacity/contract.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { FakeSystemStats } from "../../ports/index.js"; +import { capacityStrategies, type CapacityStrategyName } from "./strategies/index.js"; +import type { CapacityDevice, CapacityStrategy } from "./strategy.js"; + +const gibibyte = 1024 ** 3; + +/** + * Behaviour every strategy owes its callers, whatever policy it implements. + * A new strategy is wired into this suite by the registry alone. + */ +function build(name: CapacityStrategyName): CapacityStrategy { + const systemStats = new FakeSystemStats({ + cpuCount: 8, + freeRamBytes: 32 * gibibyte, + totalRamBytes: 32 * gibibyte, + }); + const definition = capacityStrategies[name]; + return definition.create(definition.defaults(systemStats) as never, systemStats); +} + +describe.each(Object.keys(capacityStrategies) as CapacityStrategyName[])( + "capacity strategy contract: %s", + (name) => { + it("permits provisioning on an empty machine", () => { + expect(build(name).canProvision("ios", [])).toEqual({ ok: true }); + }); + + it("reports a positive device limit for both platforms", () => { + const strategy = build(name); + + expect(strategy.deviceLimit("ios")).toBeGreaterThan(0); + expect(strategy.deviceLimit("android")).toBeGreaterThan(0); + }); + + it("ignores deleted devices when deciding whether another may be created", () => { + const strategy = build(name); + const deleted: CapacityDevice[] = Array.from({ length: 50 }, () => ({ + platform: "ios", + state: "deleted", + })); + + expect(strategy.canProvision("ios", deleted)).toEqual({ ok: true }); + }); + + it("counts a reservation against running capacity exactly like a running device", () => { + const strategy = build(name); + const reserved = strategy.runningCapacity([], ["ios"]).ios; + const running = strategy.runningCapacity([{ platform: "ios", state: "ready" }], []).ios; + + expect(reserved.reserved).toBe(1); + expect(running.running).toBe(1); + expect(reserved.maxRunning).toBe(running.maxRunning); + }); + + it("refuses a running reservation once the platform is saturated, with a limit reason", () => { + const strategy = build(name); + const saturated: CapacityDevice[] = Array.from( + { length: strategy.runningCapacity([], []).global.maxRunning }, + () => ({ platform: "ios", state: "ready" }), + ); + const decision = strategy.canReserveRunning("ios", saturated, []); + + expect(decision.ok).toBe(false); + if (decision.ok) throw new Error("expected a refusal"); + expect(["global-running-limit", "platform-running-limit"]).toContain(decision.reason); + }); + + it("does not count devices in non-running states towards running capacity", () => { + const capacity = build(name).runningCapacity([{ platform: "ios", state: "shutdown" }], []); + + expect(capacity.ios.running).toBe(0); + expect(capacity.global.running).toBe(0); + }); + + it("treats an unknown lifecycle state as not running rather than throwing", () => { + const capacity = build(name).runningCapacity( + [{ platform: "android", state: "unknown-to-core" }], + [], + ); + + expect(capacity.android.running).toBe(0); + }); + }, +); diff --git a/src/core/capacity-coordinator.test.ts b/src/core/capacity/coordinator.test.ts similarity index 63% rename from src/core/capacity-coordinator.test.ts rename to src/core/capacity/coordinator.test.ts index 77006b3..f3b3e3d 100644 --- a/src/core/capacity-coordinator.test.ts +++ b/src/core/capacity/coordinator.test.ts @@ -1,53 +1,28 @@ import { describe, expect, it } from "vitest"; -import { FakeSystemStats } from "../ports/index.js"; -import type { Config } from "./config.js"; -import { CapacityCoordinator } from "./capacity-coordinator.js"; +import { FakeSystemStats } from "../../ports/index.js"; +import { CapacityCoordinator } from "./coordinator.js"; +import { resourceStrategy } from "./strategies/resource/index.js"; const gibibyte = 1024 ** 3; -const config: Config = { - diskPressure: { freeBytesThreshold: 10 * gibibyte }, - eventBuffer: { capacity: 1000 }, - health: { - enabled: true, - maxConcurrentRecoveries: 1, - maxRecoveryAttempts: 3, - probeIntervalMs: 30_000, - recoveryBackoffMs: 5_000, - stableObservations: 2, - }, - stalledTransition: { thresholdMultiplier: 3, minimumThresholdMs: 60_000 }, - idle: { deleteAfterMs: 60 * 60_000, shutdownAfterMs: 10 * 60_000 }, - warmPool: { - quarantine: { - maxRetries: 3, - maxRetryBackoffMs: 300_000, - retryBackoffMs: 30_000, - retryBackoffMultiplier: 2, - }, - }, - lease: { - detachedTtlMs: 15 * 60_000, - heldTtlBackstopMs: 60 * 60_000, - heartbeatIntervalMs: 5 * 60_000, - }, - limits: { - android: { maxDevices: 4, maxRunning: 2 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 2, - }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: 1.5 * gibibyte }, - log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, -}; function coordinator(): CapacityCoordinator { return new CapacityCoordinator( - config, - new FakeSystemStats({ - cpuCount: 8, - freeRamBytes: 32 * gibibyte, - totalRamBytes: 32 * gibibyte, - }), + resourceStrategy.create( + { + limits: { + android: { maxDevices: 4, maxRunning: 2 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 2, + }, + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: 1.5 * gibibyte }, + }, + new FakeSystemStats({ + cpuCount: 8, + freeRamBytes: 32 * gibibyte, + totalRamBytes: 32 * gibibyte, + }), + ), ); } @@ -107,4 +82,8 @@ describe("CapacityCoordinator", () => { ios: { maxRunning: 1, overLimit: false, reserved: 1, running: 0 }, }); }); + + it("delegates the device ceiling to the strategy it was given", () => { + expect(coordinator().deviceLimit("android")).toBe(4); + }); }); diff --git a/src/core/capacity-coordinator.ts b/src/core/capacity/coordinator.ts similarity index 62% rename from src/core/capacity-coordinator.ts rename to src/core/capacity/coordinator.ts index 79a9706..9144c9e 100644 --- a/src/core/capacity-coordinator.ts +++ b/src/core/capacity/coordinator.ts @@ -1,14 +1,11 @@ -import type { SystemStats } from "../ports/index.js"; -import { - canProvision, - canReserveRunning, - runningCapacity, - type CapacityDecision, - type CapacityDevice, - type CapacityPlatform, - type RunningCapacity, -} from "./capacity.js"; -import type { Config } from "./config.js"; +import type { + CapacityDecision, + CapacityDevice, + CapacityPlatform, + CapacityRefusal, + CapacityStrategy, + RunningCapacity, +} from "./strategy.js"; /** A releasable capacity reservation. Releasing it more than once is safe. */ export interface CapacityReservation { @@ -17,51 +14,42 @@ export interface CapacityReservation { export type CapacityReservationAttempt = | { readonly ok: true; readonly reservation: CapacityReservation } - | Exclude; + | CapacityRefusal; interface ReservationEntry { readonly platform: CapacityPlatform; } /** - * Stateful accounting around the pure capacity functions. + * Stateful accounting around a pure capacity strategy. * * It deliberately has no knowledge of queueing, device selection, registry - * mutation, or drivers. Callers supply a fresh registry snapshot for every - * decision and retain reservations until their corresponding operation ends. + * mutation, drivers, or of which strategy it is holding. Callers supply a fresh + * registry snapshot for every decision and retain reservations until their + * corresponding operation ends. */ export class CapacityCoordinator { readonly #provisioningReservations: ReservationEntry[] = []; readonly #runningReservations: ReservationEntry[] = []; - constructor( - private readonly config: Config, - private readonly systemStats: SystemStats, - ) {} + constructor(private readonly strategy: CapacityStrategy) {} /** * Reserves both a future device slot and its future running slot. - * Provisioning counts against RAM/device limits and running limits until - * released, including before the device appears in a registry snapshot. + * Provisioning counts against the strategy's device budget and running limits + * until released, including before the device appears in a registry snapshot. */ tryReserveProvisioning( platform: CapacityPlatform, devices: readonly CapacityDevice[], ): CapacityReservationAttempt { - const provision = canProvision( - platform, - [...devices, ...this.#provisioningReservations.map(asProvisioningDevice)], - this.config, - this.systemStats, - ); + const provision = this.strategy.canProvision(platform, [ + ...devices, + ...this.#provisioningReservations.map(asProvisioningDevice), + ]); if (!provision.ok) return provision; - const running = canReserveRunning( - platform, - devices, - this.#allRunningReservations(), - this.config, - ); + const running = this.canReserveRunning(platform, devices); if (!running.ok) return running; const reservation = { platform }; @@ -77,12 +65,7 @@ export class CapacityCoordinator { platform: CapacityPlatform, devices: readonly CapacityDevice[], ): CapacityReservationAttempt { - const decision = canReserveRunning( - platform, - devices, - this.#allRunningReservations(), - this.config, - ); + const decision = this.canReserveRunning(platform, devices); if (!decision.ok) return decision; const reservation = { platform }; @@ -94,11 +77,15 @@ export class CapacityCoordinator { platform: CapacityPlatform, devices: readonly CapacityDevice[], ): CapacityDecision { - return canReserveRunning(platform, devices, this.#allRunningReservations(), this.config); + return this.strategy.canReserveRunning(platform, devices, this.#allRunningReservations()); } runningCapacity(devices: readonly CapacityDevice[]): RunningCapacity { - return runningCapacity(devices, this.#allRunningReservations(), this.config); + return this.strategy.runningCapacity(devices, this.#allRunningReservations()); + } + + deviceLimit(platform: CapacityPlatform): number { + return this.strategy.deviceLimit(platform); } #allRunningReservations(): CapacityPlatform[] { diff --git a/src/core/capacity/index.ts b/src/core/capacity/index.ts new file mode 100644 index 0000000..b9a0179 --- /dev/null +++ b/src/core/capacity/index.ts @@ -0,0 +1,20 @@ +export { CapacityCoordinator } from "./coordinator.js"; +export type { CapacityReservation } from "./coordinator.js"; +export type { CapacityLimits } from "./limits.js"; +export { + capacityStrategyNames, + capacityStrategyValidator, + createCapacityStrategy, + DEFAULT_CAPACITY_STRATEGY, + defaultCapacityOptions, + isCapacityStrategyName, + type CapacityConfig, + type CapacityStrategyName, + type ResourceStrategyOptions, +} from "./strategies/index.js"; +export type { + CapacityDecision, + CapacityDevice, + CapacityPlatform, + RunningCapacity, +} from "./strategy.js"; diff --git a/src/core/capacity/limits.ts b/src/core/capacity/limits.ts new file mode 100644 index 0000000..7a09cee --- /dev/null +++ b/src/core/capacity/limits.ts @@ -0,0 +1,72 @@ +import type { + CapacityDecision, + CapacityDevice, + CapacityPlatform, + RunningCapacity, + RunningCapacityEntry, +} from "./strategy.js"; + +/** + * The running/device ceilings every strategy ultimately reduces to. Strategies + * differ in how they arrive at these numbers and in what extra gates they apply + * on top, not in how the ceilings themselves are enforced. + */ +export interface CapacityLimits { + readonly maxRunning: number; + readonly ios: { readonly maxDevices: number; readonly maxRunning: number }; + readonly android: { readonly maxDevices: number; readonly maxRunning: number }; +} + +const RUNNING_STATES = new Set(["ready", "leased", "reclaiming", "quarantined"]); + +export function runningCapacity( + devices: readonly CapacityDevice[], + reservations: readonly CapacityPlatform[], + limits: CapacityLimits, +): RunningCapacity { + const entry = (platform?: CapacityPlatform): RunningCapacityEntry => { + const running = devices.filter( + (device) => + RUNNING_STATES.has(device.state) && + (platform === undefined || device.platform === platform), + ).length; + const reserved = reservations.filter( + (reservation) => platform === undefined || reservation === platform, + ).length; + const maxRunning = platform === undefined ? limits.maxRunning : limits[platform].maxRunning; + return { maxRunning, overLimit: running + reserved > maxRunning, reserved, running }; + }; + return { android: entry("android"), global: entry(), ios: entry("ios") }; +} + +export function canReserveRunning( + platform: CapacityPlatform, + devices: readonly CapacityDevice[], + reservations: readonly CapacityPlatform[], + limits: CapacityLimits, +): CapacityDecision { + const capacity = runningCapacity(devices, reservations, limits); + if (capacity.global.running + capacity.global.reserved >= capacity.global.maxRunning) { + return { ok: false, reason: "global-running-limit" }; + } + const platformCapacity = capacity[platform]; + if (platformCapacity.running + platformCapacity.reserved >= platformCapacity.maxRunning) { + return { ok: false, reason: "platform-running-limit" }; + } + return { ok: true }; +} + +export function withinDeviceLimit( + platform: CapacityPlatform, + devices: readonly CapacityDevice[], + limits: CapacityLimits, +): boolean { + return ( + activeDevices(devices).filter((device) => device.platform === platform).length < + limits[platform].maxDevices + ); +} + +export function activeDevices(devices: readonly CapacityDevice[]): readonly CapacityDevice[] { + return devices.filter((device) => device.state !== "deleted"); +} diff --git a/src/core/capacity/strategies/fixed/index.test.ts b/src/core/capacity/strategies/fixed/index.test.ts new file mode 100644 index 0000000..b34e01a --- /dev/null +++ b/src/core/capacity/strategies/fixed/index.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { FakeSystemStats } from "../../../../ports/index.js"; +import type { CapacityDevice } from "../../strategy.js"; +import { fixedStrategy, type FixedStrategyOptions } from "./index.js"; + +const gibibyte = 1024 ** 3; + +function strategy(options: FixedStrategyOptions) { + return fixedStrategy.create( + options, + // Passed for interface parity only: nothing in this strategy reads it. + new FakeSystemStats({ cpuCount: 1, freeRamBytes: gibibyte, totalRamBytes: gibibyte }), + ); +} + +function ready(platform: "ios" | "android", count: number): CapacityDevice[] { + return Array.from({ length: count }, () => ({ platform, state: "ready" })); +} + +describe("fixed strategy", () => { + it("treats a bare maxRunning as a complete configuration", () => { + const fixed = strategy({ maxRunning: 4 }); + + expect(fixed.deviceLimit("ios")).toBe(4); + expect(fixed.deviceLimit("android")).toBe(4); + expect(fixed.runningCapacity([], [])).toEqual({ + android: { maxRunning: 4, overLimit: false, reserved: 0, running: 0 }, + global: { maxRunning: 4, overLimit: false, reserved: 0, running: 0 }, + ios: { maxRunning: 4, overLimit: false, reserved: 0, running: 0 }, + }); + }); + + it("pins the global running count regardless of platform mix", () => { + const fixed = strategy({ maxRunning: 2 }); + const full = [...ready("ios", 1), ...ready("android", 1)]; + + expect(fixed.canReserveRunning("ios", full, [])).toEqual({ + ok: false, + reason: "global-running-limit", + }); + }); + + it("carves the budget up when per-platform overrides are given", () => { + const fixed = strategy({ android: { maxRunning: 1 }, ios: { maxRunning: 3 }, maxRunning: 3 }); + + expect(fixed.canReserveRunning("android", ready("android", 1), [])).toEqual({ + ok: false, + reason: "platform-running-limit", + }); + expect(fixed.canReserveRunning("ios", ready("ios", 2), [])).toEqual({ ok: true }); + }); + + it("lets maxDevices exceed maxRunning so shut-down devices can be kept around", () => { + const fixed = strategy({ ios: { maxDevices: 5, maxRunning: 2 }, maxRunning: 2 }); + + expect(fixed.canProvision("ios", ready("ios", 4))).toEqual({ ok: true }); + expect(fixed.canProvision("ios", ready("ios", 5))).toEqual({ + ok: false, + reason: "device-limit", + }); + }); + + it("never refuses on RAM, however little the machine has", () => { + const fixed = fixedStrategy.create( + { maxRunning: 8 }, + new FakeSystemStats({ cpuCount: 1, freeRamBytes: 0, totalRamBytes: 0 }), + ); + + expect(fixed.canProvision("android", ready("android", 7))).toEqual({ ok: true }); + }); + + it("ignores deleted devices when counting against the pin", () => { + const fixed = strategy({ maxRunning: 1 }); + + expect(fixed.canProvision("ios", [{ platform: "ios", state: "deleted" }])).toEqual({ + ok: true, + }); + }); + + it("defaults to a machine-independent pin", () => { + expect( + fixedStrategy.defaults( + new FakeSystemStats({ cpuCount: 64, freeRamBytes: 0, totalRamBytes: 512 * gibibyte }), + ), + ).toEqual({ maxRunning: 2 }); + }); +}); diff --git a/src/core/capacity/strategies/fixed/index.ts b/src/core/capacity/strategies/fixed/index.ts new file mode 100644 index 0000000..e7a8b0d --- /dev/null +++ b/src/core/capacity/strategies/fixed/index.ts @@ -0,0 +1,101 @@ +import { objectValidator, positiveInteger } from "../../../validation.js"; +import { + canReserveRunning, + runningCapacity, + withinDeviceLimit, + type CapacityLimits, +} from "../../limits.js"; +import { + defineCapacityStrategy, + type CapacityDecision, + type CapacityDevice, + type CapacityPlatform, + type CapacityStrategy, + type RunningCapacity, +} from "../../strategy.js"; + +/** + * A pinned number of devices, with no machine inspection at all: no RAM budget, + * no CPU-derived defaults. `maxRunning` alone is a complete configuration -- + * the per-platform blocks exist only to carve that budget up, and each field + * falls back to the global number when omitted. + */ +export interface FixedStrategyOptions { + readonly maxRunning: number; + readonly ios?: PlatformOptions; + readonly android?: PlatformOptions; +} + +interface PlatformOptions { + readonly maxDevices?: number; + readonly maxRunning?: number; +} + +class FixedCapacityStrategy implements CapacityStrategy { + readonly #limits: CapacityLimits; + + constructor(options: FixedStrategyOptions) { + this.#limits = resolveLimits(options); + } + + canProvision(platform: CapacityPlatform, devices: readonly CapacityDevice[]): CapacityDecision { + return withinDeviceLimit(platform, devices, this.#limits) + ? { ok: true } + : { ok: false, reason: "device-limit" }; + } + + canReserveRunning( + platform: CapacityPlatform, + devices: readonly CapacityDevice[], + reservations: readonly CapacityPlatform[], + ): CapacityDecision { + return canReserveRunning(platform, devices, reservations, this.#limits); + } + + runningCapacity( + devices: readonly CapacityDevice[], + reservations: readonly CapacityPlatform[], + ): RunningCapacity { + return runningCapacity(devices, reservations, this.#limits); + } + + deviceLimit(platform: CapacityPlatform): number { + return this.#limits[platform].maxDevices; + } +} + +function resolveLimits(options: FixedStrategyOptions): CapacityLimits { + const platform = (overrides: PlatformOptions | undefined) => { + const maxRunning = overrides?.maxRunning ?? options.maxRunning; + return { maxDevices: overrides?.maxDevices ?? maxRunning, maxRunning }; + }; + + return { + maxRunning: options.maxRunning, + ios: platform(options.ios), + android: platform(options.android), + }; +} + +const platformValidator = objectValidator({ + maxDevices: positiveInteger, + maxRunning: positiveInteger, +}); + +export const fixedStrategy = defineCapacityStrategy({ + name: "fixed" as const, + + defaults(): FixedStrategyOptions { + return { maxRunning: 2 }; + }, + + validator: objectValidator({ + maxRunning: positiveInteger, + ios: platformValidator, + android: platformValidator, + }), + + create(options: FixedStrategyOptions): CapacityStrategy { + return new FixedCapacityStrategy(options); + }, +}); diff --git a/src/core/capacity/strategies/index.ts b/src/core/capacity/strategies/index.ts new file mode 100644 index 0000000..2425d88 --- /dev/null +++ b/src/core/capacity/strategies/index.ts @@ -0,0 +1,67 @@ +import type { SystemStats } from "../../../ports/index.js"; +import type { Validator } from "../../validation.js"; +import type { CapacityStrategy, CapacityStrategyDefinition } from "../strategy.js"; +import { fixedStrategy } from "./fixed/index.js"; +import { resourceStrategy } from "./resource/index.js"; + +/** + * The registry. Adding a strategy means adding a directory next to these and one + * line here -- the config type, its validation, and the coordinator all follow + * from this map. + */ +export const capacityStrategies = { + fixed: fixedStrategy, + resource: resourceStrategy, +} as const; + +export const DEFAULT_CAPACITY_STRATEGY = "resource"; + +export type CapacityStrategyName = keyof typeof capacityStrategies; + +export const capacityStrategyNames = Object.keys( + capacityStrategies, +) as readonly CapacityStrategyName[]; + +type OptionsFor = + (typeof capacityStrategies)[Name] extends CapacityStrategyDefinition + ? Options + : never; + +/** + * Discriminated on `strategy`, so a config only ever carries the options block + * belonging to the strategy it selected. + */ +export type CapacityConfig = { + [Name in CapacityStrategyName]: { + readonly strategy: Name; + readonly config: OptionsFor; + }; +}[CapacityStrategyName]; + +export function isCapacityStrategyName(value: unknown): value is CapacityStrategyName { + return typeof value === "string" && value in capacityStrategies; +} + +export function capacityStrategyValidator(name: CapacityStrategyName): Validator { + return capacityStrategies[name].validator; +} + +export function defaultCapacityOptions( + name: Name, + systemStats: SystemStats, +): OptionsFor { + return capacityStrategies[name].defaults(systemStats) as OptionsFor; +} + +export function createCapacityStrategy( + capacity: CapacityConfig, + systemStats: SystemStats, +): CapacityStrategy { + const definition = capacityStrategies[capacity.strategy] as CapacityStrategyDefinition< + CapacityStrategyName, + unknown + >; + return definition.create(capacity.config, systemStats); +} + +export type { ResourceStrategyOptions } from "./resource/index.js"; diff --git a/src/core/capacity/strategies/resource/index.test.ts b/src/core/capacity/strategies/resource/index.test.ts new file mode 100644 index 0000000..c073d40 --- /dev/null +++ b/src/core/capacity/strategies/resource/index.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { FakeSystemStats } from "../../../../ports/index.js"; +import type { CapacityDevice, CapacityStrategy } from "../../strategy.js"; +import { resourceStrategy, type ResourceStrategyOptions } from "./index.js"; + +const gibibyte = 1024 ** 3; + +const options: ResourceStrategyOptions = { + limits: { + android: { maxDevices: 4, maxRunning: 2 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 2, + }, + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: 1.5 * gibibyte }, +}; + +function withRam(totalRamBytes: number): CapacityStrategy { + return resourceStrategy.create( + options, + new FakeSystemStats({ cpuCount: 8, freeRamBytes: totalRamBytes, totalRamBytes }), + ); +} + +describe("resource strategy defaults", () => { + it("derives device limits from the machine", () => { + const defaults = resourceStrategy.defaults( + new FakeSystemStats({ + cpuCount: 8, + freeRamBytes: 16 * gibibyte, + totalRamBytes: 32 * gibibyte, + }), + ); + + expect(defaults.limits).toEqual({ + android: { maxDevices: 2, maxRunning: 2 }, + ios: { maxDevices: 4, maxRunning: 4 }, + maxRunning: 6, + }); + expect(defaults.ramBudget).toEqual({ + androidBytesPerDevice: 4 * gibibyte, + iosBytesPerDevice: 1.5 * gibibyte, + }); + }); + + it("keeps at least one device per platform on a small machine", () => { + const defaults = resourceStrategy.defaults( + new FakeSystemStats({ cpuCount: 1, freeRamBytes: gibibyte, totalRamBytes: 2 * gibibyte }), + ); + + expect(defaults.limits.ios.maxDevices).toBe(1); + expect(defaults.limits.android.maxDevices).toBe(1); + }); +}); + +describe("resource strategy provisioning", () => { + it("refuses provisioning at the platform device limit", () => { + const devices: CapacityDevice[] = [{ platform: "ios", state: "ready" }]; + + expect(withRam(32 * gibibyte).canProvision("ios", devices)).toEqual({ + ok: false, + reason: "device-limit", + }); + }); + + it("refuses provisioning when another device would exceed the RAM budget", () => { + const devices: CapacityDevice[] = [{ platform: "android", state: "ready" }]; + + expect(withRam(11 * gibibyte).canProvision("android", devices)).toEqual({ + ok: false, + reason: "ram-budget", + }); + }); + + it("does not count deleted devices against capacity", () => { + const devices: CapacityDevice[] = [{ platform: "ios", state: "deleted" }]; + + expect(withRam(32 * gibibyte).canProvision("ios", devices)).toEqual({ ok: true }); + }); + + it("accounts for active devices on both platforms in the shared RAM budget", () => { + const devices: CapacityDevice[] = [{ platform: "android", state: "ready" }]; + + expect(withRam(9 * gibibyte).canProvision("ios", devices)).toEqual({ + ok: false, + reason: "ram-budget", + }); + }); + + it("permits provisioning when allocation exactly reaches the RAM budget", () => { + const devices: CapacityDevice[] = [{ platform: "android", state: "ready" }]; + + expect(withRam(9.5 * gibibyte).canProvision("ios", devices)).toEqual({ ok: true }); + }); + + it("reports the managed-device ceiling per platform", () => { + const strategy = withRam(32 * gibibyte); + + expect(strategy.deviceLimit("ios")).toBe(1); + expect(strategy.deviceLimit("android")).toBe(4); + }); +}); + +describe("resource strategy running capacity", () => { + it("counts only running lifecycle states and reservations", () => { + expect( + withRam(32 * gibibyte).runningCapacity( + [ + { platform: "ios", state: "ready" }, + { platform: "android", state: "shutdown" }, + { platform: "android", state: "deleted" }, + ], + ["android"], + ), + ).toEqual({ + global: { maxRunning: 2, overLimit: false, reserved: 1, running: 1 }, + ios: { maxRunning: 1, overLimit: false, reserved: 0, running: 1 }, + android: { maxRunning: 2, overLimit: false, reserved: 1, running: 0 }, + }); + }); + + it("requires both global and platform room", () => { + const strategy = withRam(32 * gibibyte); + const globallyFull = [ + { platform: "ios", state: "ready" }, + { platform: "android", state: "ready" }, + ] as const; + + expect(strategy.canReserveRunning("android", globallyFull, [])).toEqual({ + ok: false, + reason: "global-running-limit", + }); + expect(strategy.canReserveRunning("ios", [{ platform: "ios", state: "leased" }], [])).toEqual({ + ok: false, + reason: "platform-running-limit", + }); + }); +}); diff --git a/src/core/capacity/strategies/resource/index.ts b/src/core/capacity/strategies/resource/index.ts new file mode 100644 index 0000000..cdc470a --- /dev/null +++ b/src/core/capacity/strategies/resource/index.ts @@ -0,0 +1,133 @@ +import type { SystemStats } from "../../../../ports/index.js"; +import { nonNegativeNumber, objectValidator, positiveInteger } from "../../../validation.js"; +import { + activeDevices, + canReserveRunning, + runningCapacity, + withinDeviceLimit, + type CapacityLimits, +} from "../../limits.js"; +import { + defineCapacityStrategy, + type CapacityDecision, + type CapacityDevice, + type CapacityPlatform, + type CapacityStrategy, + type RunningCapacity, +} from "../../strategy.js"; + +const GIBIBYTE = 1024 ** 3; +const OS_RAM_RESERVE_BYTES = 4 * GIBIBYTE; + +/** + * Device and running ceilings derived from the machine, with a RAM budget gate + * on top: a device may be created only if its budgeted RAM still fits under the + * machine's total minus a reserve left for the OS. + */ +export interface ResourceStrategyOptions { + readonly limits: CapacityLimits; + readonly ramBudget: { + readonly iosBytesPerDevice: number; + readonly androidBytesPerDevice: number; + }; +} + +class ResourceCapacityStrategy implements CapacityStrategy { + constructor( + private readonly options: ResourceStrategyOptions, + private readonly systemStats: SystemStats, + ) {} + + canProvision(platform: CapacityPlatform, devices: readonly CapacityDevice[]): CapacityDecision { + if (!withinDeviceLimit(platform, devices, this.options.limits)) { + return { ok: false, reason: "device-limit" }; + } + + const usedRamBytes = activeDevices(devices).reduce( + (total, device) => total + this.#ramBudget(device.platform), + 0, + ); + const availableRamBytes = this.systemStats.totalRamBytes() - OS_RAM_RESERVE_BYTES; + + if (usedRamBytes + this.#ramBudget(platform) > availableRamBytes) { + return { ok: false, reason: "ram-budget" }; + } + + return { ok: true }; + } + + canReserveRunning( + platform: CapacityPlatform, + devices: readonly CapacityDevice[], + reservations: readonly CapacityPlatform[], + ): CapacityDecision { + return canReserveRunning(platform, devices, reservations, this.options.limits); + } + + runningCapacity( + devices: readonly CapacityDevice[], + reservations: readonly CapacityPlatform[], + ): RunningCapacity { + return runningCapacity(devices, reservations, this.options.limits); + } + + deviceLimit(platform: CapacityPlatform): number { + return this.options.limits[platform].maxDevices; + } + + #ramBudget(platform: CapacityPlatform): number { + return platform === "ios" + ? this.options.ramBudget.iosBytesPerDevice + : this.options.ramBudget.androidBytesPerDevice; + } +} + +const limitsValidator = objectValidator({ + maxRunning: positiveInteger, + ios: objectValidator({ maxDevices: positiveInteger, maxRunning: positiveInteger }), + android: objectValidator({ maxDevices: positiveInteger, maxRunning: positiveInteger }), +}); + +const ramBudgetValidator = objectValidator({ + iosBytesPerDevice: nonNegativeNumber, + androidBytesPerDevice: nonNegativeNumber, +}); + +/** Exported for the legacy top-level `limits` / `ramBudget` keys in `config.ts`. */ +export const resourceOptionValidators = { + limits: limitsValidator, + ramBudget: ramBudgetValidator, +}; + +export const resourceStrategy = defineCapacityStrategy({ + name: "resource" as const, + + defaults(systemStats: SystemStats): ResourceStrategyOptions { + const cpuCount = systemStats.cpuCount(); + const totalRamGb = systemStats.totalRamBytes() / GIBIBYTE; + + const iosMaxDevices = Math.max(1, Math.floor(cpuCount / 2)); + const androidMaxDevices = Math.max( + 1, + Math.min(Math.floor(cpuCount / 4), Math.floor(totalRamGb / 8)), + ); + + return { + limits: { + maxRunning: iosMaxDevices + androidMaxDevices, + ios: { maxDevices: iosMaxDevices, maxRunning: iosMaxDevices }, + android: { maxDevices: androidMaxDevices, maxRunning: androidMaxDevices }, + }, + ramBudget: { + iosBytesPerDevice: 1.5 * GIBIBYTE, + androidBytesPerDevice: 4 * GIBIBYTE, + }, + }; + }, + + validator: objectValidator(resourceOptionValidators), + + create(options: ResourceStrategyOptions, systemStats: SystemStats): CapacityStrategy { + return new ResourceCapacityStrategy(options, systemStats); + }, +}); diff --git a/src/core/capacity/strategy.ts b/src/core/capacity/strategy.ts new file mode 100644 index 0000000..de7c18d --- /dev/null +++ b/src/core/capacity/strategy.ts @@ -0,0 +1,85 @@ +import type { SystemStats } from "../../ports/index.js"; +import type { Validator } from "../validation.js"; + +export type CapacityPlatform = "ios" | "android"; + +export interface CapacityDevice { + readonly platform: CapacityPlatform; + readonly state: string; +} + +export type CapacityRefusalReason = + | "device-limit" + | "ram-budget" + | "global-running-limit" + | "platform-running-limit"; + +export interface CapacityRefusal { + readonly ok: false; + readonly reason: CapacityRefusalReason; +} + +export type CapacityDecision = { readonly ok: true } | CapacityRefusal; + +export interface RunningCapacityEntry { + readonly running: number; + readonly maxRunning: number; + readonly reserved: number; + readonly overLimit: boolean; +} + +export interface RunningCapacity { + readonly global: RunningCapacityEntry; + readonly ios: RunningCapacityEntry; + readonly android: RunningCapacityEntry; +} + +/** + * Decides how many devices may exist and run at once. + * + * Implementations are pure: every decision is taken from the snapshot and + * reservations handed in by `CapacityCoordinator`, which owns all the stateful + * accounting. A strategy knows nothing about queueing, device selection, + * registry mutation, or drivers. + */ +export interface CapacityStrategy { + /** + * Whether another device may be created. `devices` includes synthetic entries + * for in-flight provisioning reservations, so a strategy sees pending work as + * though it had already landed in the registry. + */ + canProvision(platform: CapacityPlatform, devices: readonly CapacityDevice[]): CapacityDecision; + + canReserveRunning( + platform: CapacityPlatform, + devices: readonly CapacityDevice[], + reservations: readonly CapacityPlatform[], + ): CapacityDecision; + + runningCapacity( + devices: readonly CapacityDevice[], + reservations: readonly CapacityPlatform[], + ): RunningCapacity; + + /** Managed-device ceiling for a platform, for reporting. */ + deviceLimit(platform: CapacityPlatform): number; +} + +/** + * A strategy's registry entry: its name, how to default and validate its own + * options block, and how to build it. Adding a strategy means adding a + * directory and one registry line -- nothing else in the config or the + * coordinator changes. + */ +export interface CapacityStrategyDefinition { + readonly name: Name; + defaults(systemStats: SystemStats): Options; + readonly validator: Validator; + create(options: Options, systemStats: SystemStats): CapacityStrategy; +} + +export function defineCapacityStrategy( + definition: CapacityStrategyDefinition, +): CapacityStrategyDefinition { + return definition; +} diff --git a/src/core/cleanup/idle-destroy.test.ts b/src/core/cleanup/idle-destroy.test.ts index dafdc9e..67dd3c5 100644 --- a/src/core/cleanup/idle-destroy.test.ts +++ b/src/core/cleanup/idle-destroy.test.ts @@ -27,12 +27,17 @@ const config: Config = { }, }, lease: { detachedTtlMs: 1, heldTtlBackstopMs: 1, heartbeatIntervalMs: 1 }, - limits: { - android: { maxDevices: 1, maxRunning: 1 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 1 + 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 1, maxRunning: 1 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 1 + 1, + }, + ramBudget: { androidBytesPerDevice: 1, iosBytesPerDevice: 1 }, + }, }, - ramBudget: { androidBytesPerDevice: 1, iosBytesPerDevice: 1 }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, }; diff --git a/src/core/cleanup/idle-shutdown.test.ts b/src/core/cleanup/idle-shutdown.test.ts index 460e198..ef3c800 100644 --- a/src/core/cleanup/idle-shutdown.test.ts +++ b/src/core/cleanup/idle-shutdown.test.ts @@ -25,12 +25,17 @@ const config: Config = { }, }, lease: { detachedTtlMs: 1, heldTtlBackstopMs: 1, heartbeatIntervalMs: 1 }, - limits: { - android: { maxDevices: 1, maxRunning: 1 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 1 + 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 1, maxRunning: 1 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 1 + 1, + }, + ramBudget: { androidBytesPerDevice: 1, iosBytesPerDevice: 1 }, + }, }, - ramBudget: { androidBytesPerDevice: 1, iosBytesPerDevice: 1 }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, }; diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 5195600..a639514 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { MemoryFilesystem, FakeSystemStats } from "../ports/index.js"; -import { loadConfig } from "./index.js"; +import type { ResourceStrategyOptions } from "./capacity/index.js"; +import { type Config, loadConfig } from "./index.js"; + +/** Narrows the capacity block for assertions on resource-strategy configs. */ +function resourceOptions(config: Config): ResourceStrategyOptions { + if (config.capacity.strategy !== "resource") throw new Error("expected the resource strategy"); + return config.capacity.config; +} const configPath = "/home/agent/.simlock/config.json"; const gibibyte = 1024 ** 3; @@ -22,11 +29,12 @@ describe("loadConfig", () => { systemStats: createStats(), }); - expect(config.limits.ios.maxDevices).toBe(Math.max(1, Math.floor(8 / 2))); - expect(config.limits.android.maxDevices).toBe( + expect(config.capacity.strategy).toBe("resource"); + expect(resourceOptions(config).limits.ios.maxDevices).toBe(Math.max(1, Math.floor(8 / 2))); + expect(resourceOptions(config).limits.android.maxDevices).toBe( Math.max(1, Math.min(Math.floor(8 / 4), Math.floor(32 / 8))), ); - expect(config.limits).toMatchObject({ + expect(resourceOptions(config).limits).toMatchObject({ android: { maxRunning: 2 }, ios: { maxRunning: 4 }, maxRunning: 6, @@ -57,7 +65,12 @@ describe("loadConfig", () => { heldTtlBackstopMs: 60 * 60_000, heartbeatIntervalMs: 5 * 60_000, }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: 1.5 * gibibyte }, + capacity: { + strategy: "resource", + config: { + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: 1.5 * gibibyte }, + }, + }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, warmPool: { quarantine: { @@ -70,7 +83,7 @@ describe("loadConfig", () => { stalledTransition: { thresholdMultiplier: 3, minimumThresholdMs: 60_000 }, }); expect(Object.isFrozen(config)).toBe(true); - expect(Object.isFrozen(config.limits)).toBe(true); + expect(Object.isFrozen(resourceOptions(config).limits)).toBe(true); }); it("applies a file-level log override", async () => { @@ -169,9 +182,9 @@ describe("loadConfig", () => { systemStats: createStats(), }); - expect(config.limits.ios.maxDevices).toBe(3); - expect(config.limits.android.maxDevices).toBe(2); - expect(config.limits).toMatchObject({ + expect(resourceOptions(config).limits.ios.maxDevices).toBe(3); + expect(resourceOptions(config).limits.android.maxDevices).toBe(2); + expect(resourceOptions(config).limits).toMatchObject({ android: { maxRunning: 1 }, ios: { maxRunning: 2 }, maxRunning: 4, @@ -193,7 +206,7 @@ describe("loadConfig", () => { systemStats: createStats(), }); - expect(config.ramBudget).toEqual({ + expect(resourceOptions(config).ramBudget).toEqual({ androidBytesPerDevice: 5 * gibibyte, iosBytesPerDevice: 1.5 * gibibyte, }); @@ -373,3 +386,153 @@ describe("loadConfig", () => { ).rejects.toThrow(path); }); }); + +describe("loadConfig capacity strategies", () => { + async function load( + contents: unknown, + options: { + readonly overrides?: Parameters[0]["overrides"]; + readonly 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(), + ...(options.overrides === undefined ? {} : { overrides: options.overrides }), + ...(options.warn === undefined ? {} : { warn: options.warn }), + }); + } + + it("selects the resource strategy when nothing names one", async () => { + const config = await load({}); + + expect(config.capacity.strategy).toBe("resource"); + }); + + it("defaults the fixed strategy to a machine-independent pin", async () => { + const config = await load({ capacity: { strategy: "fixed" } }); + + expect(config.capacity).toEqual({ strategy: "fixed", config: { maxRunning: 2 } }); + }); + + it("pins concurrency from a single key", async () => { + const config = await load({ capacity: { strategy: "fixed", config: { maxRunning: 4 } } }); + + expect(config.capacity.config).toEqual({ maxRunning: 4 }); + }); + + it("lets an override switch the strategy chosen by the file", async () => { + const config = await load( + { capacity: { strategy: "resource" } }, + { overrides: { capacity: { strategy: "fixed", config: { maxRunning: 6 } } } }, + ); + + expect(config.capacity).toEqual({ strategy: "fixed", config: { maxRunning: 6 } }); + }); + + it("starts from the selected strategy's defaults, not the default strategy's", async () => { + const config = await load({ capacity: { strategy: "fixed", config: { maxRunning: 3 } } }); + + expect(config.capacity.config).not.toHaveProperty("ramBudget"); + expect(config.capacity.config).not.toHaveProperty("limits"); + }); + + it("rejects a strategy name with no registered implementation", async () => { + await expect(load({ capacity: { strategy: "vibes" } })).rejects.toThrow("capacity.strategy"); + }); + + it("hands capacity.config to the selected strategy's own validator", async () => { + await expect( + load({ capacity: { strategy: "fixed", config: { maxRunning: 0 } } }), + ).rejects.toThrow("capacity.config.maxRunning"); + }); + + it("warns when capacity.config carries another strategy's keys", async () => { + const warn = vi.fn(); + await load({ capacity: { strategy: "fixed", config: { ramBudget: {} } } }, { warn }); + + expect(warn).toHaveBeenCalledWith('Unknown config key: "capacity.config.ramBudget"'); + }); +}); + +describe("loadConfig legacy capacity keys", () => { + async function load( + contents: unknown, + options: { + readonly overrides?: Parameters[0]["overrides"]; + readonly 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(), + ...(options.overrides === undefined ? {} : { overrides: options.overrides }), + ...(options.warn === undefined ? {} : { warn: options.warn }), + }); + } + + it("folds top-level limits and ramBudget into the resource strategy's options", async () => { + const warn = vi.fn(); + const config = await load( + { + limits: { ios: { maxDevices: 3, maxRunning: 3 }, maxRunning: 5 }, + ramBudget: { iosBytesPerDevice: 2 * gibibyte }, + }, + { warn }, + ); + + expect(config.capacity.strategy).toBe("resource"); + expect(resourceOptions(config).limits).toMatchObject({ + ios: { maxDevices: 3, maxRunning: 3 }, + maxRunning: 5, + }); + expect(resourceOptions(config).ramBudget.iosBytesPerDevice).toBe(2 * gibibyte); + expect(warn).not.toHaveBeenCalled(); + }); + + it("prefers capacity.config over the legacy spelling within one layer", async () => { + const config = await load({ + capacity: { strategy: "resource", config: { limits: { maxRunning: 9 } } }, + limits: { maxRunning: 2 }, + }); + + expect(resourceOptions(config).limits.maxRunning).toBe(9); + }); + + it("keeps layer precedence when the layers disagree about spelling", async () => { + const config = await load( + { limits: { maxRunning: 8 } }, + { overrides: { capacity: { strategy: "resource", config: { limits: { maxRunning: 4 } } } } }, + ); + + expect(resourceOptions(config).limits.maxRunning).toBe(4); + }); + + it("lets a legacy override win over a capacity.config file value", async () => { + const config = await load( + { capacity: { strategy: "resource", config: { limits: { maxRunning: 8 } } } }, + { overrides: { limits: { maxRunning: 4 } } }, + ); + + expect(resourceOptions(config).limits.maxRunning).toBe(4); + }); + + it("warns and ignores legacy keys when another strategy is selected", async () => { + const warn = vi.fn(); + const config = await load( + { capacity: { strategy: "fixed", config: { maxRunning: 3 } }, limits: { maxRunning: 8 } }, + { warn }, + ); + + expect(config.capacity).toEqual({ strategy: "fixed", config: { maxRunning: 3 } }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Ignoring limits")); + }); +}); diff --git a/src/core/config.ts b/src/core/config.ts index ab2bb35..b426c14 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,19 +1,35 @@ import type { Filesystem, LogLevel, SystemStats } from "../ports/index.js"; - -const GIBIBYTE = 1024 ** 3; +import { + capacityStrategyNames, + capacityStrategyValidator, + DEFAULT_CAPACITY_STRATEGY, + defaultCapacityOptions, + isCapacityStrategyName, + type CapacityConfig, + type CapacityLimits, + type CapacityStrategyName, + type ResourceStrategyOptions, +} from "./capacity/index.js"; +import { resourceOptionValidators } from "./capacity/strategies/resource/index.js"; +import { + booleanValue, + ConfigError, + invalidValue, + nonNegativeNumber, + numberAtLeast, + objectValidator, + positiveInteger, + positiveNumber, + requireObject, + stringUnion, + type Validator, + type Warn, +} from "./validation.js"; const DEFAULT_CONFIG_PATH = "~/.simlock/config.json"; export interface Config { - readonly limits: { - readonly maxRunning: number; - readonly ios: { readonly maxDevices: number; readonly maxRunning: number }; - readonly android: { readonly maxDevices: number; readonly maxRunning: number }; - }; - readonly ramBudget: { - readonly iosBytesPerDevice: number; - readonly androidBytesPerDevice: number; - }; + readonly capacity: CapacityConfig; readonly idle: { readonly shutdownAfterMs: number; readonly deleteAfterMs: number; @@ -58,22 +74,27 @@ export interface Config { }; } -export type ConfigOverrides = DeepPartial; +/** + * The pre-`capacity` spelling of the resource strategy's options. Still accepted + * everywhere the new shape is, and folded into `capacity.config` before anything + * downstream sees it, so existing config files keep working untouched. + */ +export interface LegacyCapacityOverrides { + readonly limits?: DeepPartial; + readonly ramBudget?: DeepPartial; +} + +export type ConfigOverrides = DeepPartial & LegacyCapacityOverrides; export interface LoadConfigOptions { readonly filesystem: Filesystem; readonly systemStats: SystemStats; readonly configPath?: string; readonly overrides?: ConfigOverrides; - readonly warn?: (message: string) => void; + readonly warn?: Warn; } -class ConfigError extends Error { - constructor(message: string) { - super(message); - this.name = "ConfigError"; - } -} +type Layer = Record; export async function loadConfig({ configPath = DEFAULT_CONFIG_PATH, @@ -82,12 +103,30 @@ export async function loadConfig({ systemStats, warn = () => {}, }: LoadConfigOptions): Promise { - const defaults = defaultConfig(systemStats); const fromFile = await readConfigFile(filesystem, configPath); - const fileConfig = validateConfigLayer(fromFile, "", warn); - const overrideConfig = validateConfigLayer(overrides ?? {}, "", warn); + const fromOverrides = overrides ?? {}; + + // The strategy has to be known before anything else can be validated or + // defaulted: it decides which options block `capacity.config` is, and which + // defaults the merge starts from. + const strategy = resolveStrategyName([fromFile, fromOverrides]); + const validators = configValidators(strategy); + + const fileConfig = normalizeLayer( + validateConfigLayer(fromFile, "", warn, validators), + strategy, + warn, + ); + const overrideConfig = normalizeLayer( + validateConfigLayer(fromOverrides, "", warn, validators), + strategy, + warn, + ); - const merged = mergeConfig(mergeConfig(defaults, fileConfig), overrideConfig); + const merged = mergeConfig( + mergeConfig(defaultConfig(systemStats, strategy), fileConfig), + overrideConfig, + ) as unknown as Config; validateHeartbeatInterval(merged); return deepFreeze(merged); } @@ -103,26 +142,84 @@ function validateHeartbeatInterval(config: Config): void { } } -function defaultConfig(systemStats: SystemStats): Config { - const cpuCount = systemStats.cpuCount(); - const totalRamGb = systemStats.totalRamBytes() / GIBIBYTE; +/** Last layer that names a strategy wins; absent everywhere means the default. */ +function resolveStrategyName(layers: readonly unknown[]): CapacityStrategyName { + let resolved: CapacityStrategyName = DEFAULT_CAPACITY_STRATEGY; + + for (const layer of layers) { + const capacity = requireObject(layer, "config")["capacity"]; + if (capacity === undefined) continue; + + const candidate = requireObject(capacity, "capacity")["strategy"]; + if (candidate === undefined) continue; + + if (!isCapacityStrategyName(candidate)) { + throw invalidValue( + "capacity.strategy", + `one of ${capacityStrategyNames.map((name) => `"${name}"`).join(", ")}`, + ); + } + + resolved = candidate; + } + + return resolved; +} + +/** The pre-`capacity` spelling of the resource strategy's options. */ +const LEGACY_RESOURCE_KEYS = ["limits", "ramBudget"] as const; + +/** + * Folds the legacy top-level keys into `capacity.config`. Runs per layer, before + * merging, so precedence between layers is unaffected by which spelling each one + * happens to use. Within a layer `capacity.config` wins. + */ +function normalizeLayer(layer: Layer, strategy: CapacityStrategyName, warn: Warn): Layer { + const legacy = pick(layer, LEGACY_RESOURCE_KEYS); + const present = Object.keys(legacy); + if (present.length === 0) return layer; + + const rest = omit(layer, LEGACY_RESOURCE_KEYS); + if (strategy !== "resource") { + warn(legacyIgnoredMessage(present, strategy)); + return rest; + } + + const capacity = asObject(rest["capacity"]); + return { + ...rest, + capacity: { ...capacity, config: mergeConfig(legacy, asObject(capacity["config"])) }, + }; +} + +function legacyIgnoredMessage(keys: readonly string[], strategy: CapacityStrategyName): string { + const subject = keys.length > 1 ? "these keys configure" : "this key configures"; + return ( + `Ignoring ${keys.join(" and ")}: ${subject} the "resource" capacity strategy, ` + + `but "${strategy}" is selected. Move the settings under "capacity.config".` + ); +} - const iosMaxDevices = Math.max(1, Math.floor(cpuCount / 2)); - const androidMaxDevices = Math.max( - 1, - Math.min(Math.floor(cpuCount / 4), Math.floor(totalRamGb / 8)), +function pick(layer: Layer, keys: readonly string[]): Layer { + return Object.fromEntries( + keys.filter((key) => layer[key] !== undefined).map((key) => [key, layer[key]]), ); +} +function omit(layer: Layer, keys: readonly string[]): Layer { + return Object.fromEntries(Object.entries(layer).filter(([key]) => !keys.includes(key))); +} + +function asObject(value: unknown): Layer { + return isObject(value) ? value : {}; +} + +function defaultConfig(systemStats: SystemStats, strategy: CapacityStrategyName): Config { return { - limits: { - maxRunning: iosMaxDevices + androidMaxDevices, - ios: { maxDevices: iosMaxDevices, maxRunning: iosMaxDevices }, - android: { maxDevices: androidMaxDevices, maxRunning: androidMaxDevices }, - }, - ramBudget: { - iosBytesPerDevice: 1.5 * GIBIBYTE, - androidBytesPerDevice: 4 * GIBIBYTE, - }, + capacity: { + strategy, + config: defaultCapacityOptions(strategy, systemStats), + } as CapacityConfig, idle: { shutdownAfterMs: 10 * 60_000, deleteAfterMs: 60 * 60_000, @@ -140,7 +237,7 @@ function defaultConfig(systemStats: SystemStats): Config { detachedTtlMs: 15 * 60_000, heartbeatIntervalMs: 5 * 60_000, }, - diskPressure: { freeBytesThreshold: 10 * GIBIBYTE }, + diskPressure: { freeBytesThreshold: 10 * 1024 ** 3 }, eventBuffer: { capacity: 1_000 }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, health: { @@ -177,14 +274,15 @@ async function readConfigFile(filesystem: Filesystem, configPath: string): Promi function validateConfigLayer( value: unknown, path: string, - warn: (message: string) => void, -): ConfigOverrides { + warn: Warn, + validators: Record, +): Layer { const object = requireObject(value, path || "config"); - const result: Record = {}; + const result: Layer = {}; for (const [key, child] of Object.entries(object)) { const childPath = path === "" ? key : `${path}.${key}`; - const validator = validators[key as keyof typeof validators]; + const validator = validators[key]; if (validator === undefined) { warn(`Unknown config key: "${childPath}"`); @@ -194,138 +292,57 @@ function validateConfigLayer( result[key] = validator(child, childPath, warn); } - return result as ConfigOverrides; -} - -type Validator = (value: unknown, path: string, warn: (message: string) => void) => unknown; - -const validators = { - limits: objectValidator({ - maxRunning: positiveInteger, - ios: objectValidator({ maxDevices: positiveInteger, maxRunning: positiveInteger }), - android: objectValidator({ maxDevices: positiveInteger, maxRunning: positiveInteger }), - }), - ramBudget: objectValidator({ - iosBytesPerDevice: nonNegativeNumber, - androidBytesPerDevice: nonNegativeNumber, - }), - idle: objectValidator({ shutdownAfterMs: nonNegativeNumber, deleteAfterMs: nonNegativeNumber }), - warmPool: objectValidator({ - quarantine: objectValidator({ - maxRetries: positiveInteger, - retryBackoffMs: nonNegativeNumber, - retryBackoffMultiplier: numberAtLeast(1), - maxRetryBackoffMs: nonNegativeNumber, - }), - }), - lease: objectValidator({ - heldTtlBackstopMs: nonNegativeNumber, - detachedTtlMs: nonNegativeNumber, - heartbeatIntervalMs: positiveInteger, - }), - diskPressure: objectValidator({ freeBytesThreshold: nonNegativeNumber }), - eventBuffer: objectValidator({ capacity: positiveInteger }), - log: objectValidator({ level: logLevel, rotateBytes: positiveInteger }), - health: objectValidator({ - enabled: booleanValue, - probeIntervalMs: positiveNumber, - stableObservations: positiveInteger, - maxRecoveryAttempts: positiveInteger, - recoveryBackoffMs: positiveNumber, - maxConcurrentRecoveries: positiveInteger, - }), - stalledTransition: objectValidator({ - thresholdMultiplier: numberAtLeast(1), - minimumThresholdMs: nonNegativeNumber, - }), -} satisfies Record; - -function objectValidator(shape: Record): Validator { - return (value, path, warn) => { - const object = requireObject(value, path); - const result: Record = {}; - - for (const [key, child] of Object.entries(object)) { - const childPath = `${path}.${key}`; - const validator = shape[key]; - - if (validator === undefined) { - warn(`Unknown config key: "${childPath}"`); - continue; - } - - result[key] = validator(child, childPath, warn); - } - - return result; - }; -} - -function positiveInteger(value: unknown, path: string): number { - if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { - throw invalidValue(path, "a positive integer"); - } - - return value; + return result; } const LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"]; -function logLevel(value: unknown, path: string): LogLevel { - if (typeof value !== "string" || !LOG_LEVELS.includes(value as LogLevel)) { - throw invalidValue(path, `one of ${LOG_LEVELS.map((level) => `"${level}"`).join(", ")}`); - } - - return value as LogLevel; -} - -function nonNegativeNumber(value: unknown, path: string): number { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - throw invalidValue(path, "a non-negative number"); - } - - return value; -} - -function positiveNumber(value: unknown, path: string): number { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { - throw invalidValue(path, "a positive number"); - } - - return value; -} - -function booleanValue(value: unknown, path: string): boolean { - if (typeof value !== "boolean") { - throw invalidValue(path, "a boolean"); - } - - return value; -} - -function numberAtLeast(minimum: number): Validator { - return (value: unknown, path: string) => { - if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) { - throw invalidValue(path, `a number >= ${minimum}`); - } - - return value; +/** + * The `capacity.config` validator is the selected strategy's own, so a strategy + * owns its options end to end and nothing here has to know their shape. + */ +function configValidators(strategy: CapacityStrategyName): Record { + return { + capacity: objectValidator({ + strategy: stringUnion(capacityStrategyNames), + config: capacityStrategyValidator(strategy), + }), + // Legacy spelling of the resource options; still type-checked here so a typo + // inside them is still reported rather than silently dropped. + ...resourceOptionValidators, + idle: objectValidator({ shutdownAfterMs: nonNegativeNumber, deleteAfterMs: nonNegativeNumber }), + warmPool: objectValidator({ + quarantine: objectValidator({ + maxRetries: positiveInteger, + retryBackoffMs: nonNegativeNumber, + retryBackoffMultiplier: numberAtLeast(1), + maxRetryBackoffMs: nonNegativeNumber, + }), + }), + lease: objectValidator({ + heldTtlBackstopMs: nonNegativeNumber, + detachedTtlMs: nonNegativeNumber, + heartbeatIntervalMs: positiveInteger, + }), + diskPressure: objectValidator({ freeBytesThreshold: nonNegativeNumber }), + eventBuffer: objectValidator({ capacity: positiveInteger }), + log: objectValidator({ level: stringUnion(LOG_LEVELS), rotateBytes: positiveInteger }), + health: objectValidator({ + enabled: booleanValue, + probeIntervalMs: positiveNumber, + stableObservations: positiveInteger, + maxRecoveryAttempts: positiveInteger, + recoveryBackoffMs: positiveNumber, + maxConcurrentRecoveries: positiveInteger, + }), + stalledTransition: objectValidator({ + thresholdMultiplier: numberAtLeast(1), + minimumThresholdMs: nonNegativeNumber, + }), }; } -function requireObject(value: unknown, path: string): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw invalidValue(path, "an object"); - } - - return value as Record; -} - -function invalidValue(path: string, expected: string): ConfigError { - return new ConfigError(`Invalid config value for "${path}": expected ${expected}`); -} - -function mergeConfig(base: Base, overrides: DeepPartial): Base { +function mergeConfig(base: Base, overrides: object): Base { const merged: Record = { ...(base as Record) }; for (const [key, override] of Object.entries(overrides)) { diff --git a/src/core/device-provisioner.test.ts b/src/core/device-provisioner.test.ts index 7c84df4..4e9409b 100644 --- a/src/core/device-provisioner.test.ts +++ b/src/core/device-provisioner.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { EventBus } from "../bus/index.js"; import { FakeClock, MemoryFilesystem } from "../ports/index.js"; -import { type CapacityReservation } from "./capacity-coordinator.js"; +import { type CapacityReservation } from "./capacity/index.js"; import { DeviceOperationClaims } from "./device-operation-claims.js"; import { DeviceProvisioner } from "./device-provisioner.js"; import { DriverCatalog } from "./driver-catalog.js"; diff --git a/src/core/device-provisioner.ts b/src/core/device-provisioner.ts index 42d5ef2..9aaaf07 100644 --- a/src/core/device-provisioner.ts +++ b/src/core/device-provisioner.ts @@ -1,5 +1,5 @@ import type { Clock } from "../ports/index.js"; -import type { CapacityReservation } from "./capacity-coordinator.js"; +import type { CapacityReservation } from "./capacity/index.js"; import type { DeviceRecord, DeviceSpec } from "./domain.js"; import { BootTimeoutError, type DriverDevice } from "./driver.js"; import { DriverCatalog } from "./driver-catalog.js"; diff --git a/src/core/doctor.test.ts b/src/core/doctor.test.ts index da1a00d..995bf4d 100644 --- a/src/core/doctor.test.ts +++ b/src/core/doctor.test.ts @@ -1286,12 +1286,17 @@ function config(stalledTransitionOverrides: Partial }, idle: { deleteAfterMs: 10, shutdownAfterMs: 5 }, lease: { detachedTtlMs: 60_000, heldTtlBackstopMs: 60_000, heartbeatIntervalMs: 15_000 }, - limits: { - android: { maxDevices: 1, maxRunning: 1 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 1 + 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 1, maxRunning: 1 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 1 + 1, + }, + ramBudget: { androidBytesPerDevice: 1, iosBytesPerDevice: 1 }, + }, }, - ramBudget: { androidBytesPerDevice: 1, iosBytesPerDevice: 1 }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, warmPool: { quarantine: { diff --git a/src/core/index.ts b/src/core/index.ts index 6259b87..6233662 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,10 +1,4 @@ export { type Config, type ConfigOverrides, loadConfig } from "./config.js"; -export { - canProvision, - canReserveRunning, - type CapacityDevice, - runningCapacity, -} from "./capacity.js"; export { type DeviceRecord, type DeviceSpec, diff --git a/src/core/lease-acquisition-coordinator.test.ts b/src/core/lease-acquisition-coordinator.test.ts index 552b87f..8237eb9 100644 --- a/src/core/lease-acquisition-coordinator.test.ts +++ b/src/core/lease-acquisition-coordinator.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { EventBus } from "../bus/index.js"; import { FakeClock, FakeSystemStats, MemoryFilesystem } from "../ports/index.js"; import { AcquisitionPlanner } from "./acquisition-planner.js"; -import { CapacityCoordinator } from "./capacity-coordinator.js"; +import { CapacityCoordinator, createCapacityStrategy } from "./capacity/index.js"; import type { Config } from "./config.js"; import { DeviceOperationClaims } from "./device-operation-claims.js"; import { DeviceProvisioner } from "./device-provisioner.js"; @@ -38,12 +38,17 @@ function config(maxDevices = 1): Config { stalledTransition: { thresholdMultiplier: 3, minimumThresholdMs: 60_000 }, idle: { deleteAfterMs: 60_000, shutdownAfterMs: 10_000 }, lease: { detachedTtlMs: 100, heldTtlBackstopMs: 100, heartbeatIntervalMs: 25 }, - limits: { - android: { maxDevices, maxRunning: 1 }, - ios: { maxDevices, maxRunning: 1 }, - maxRunning: 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices, maxRunning: 1 }, + ios: { maxDevices, maxRunning: 1 }, + maxRunning: 1, + }, + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, + }, }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, warmPool: { quarantine: { @@ -77,8 +82,14 @@ async function createHarness( const claims = new DeviceOperationClaims(); const catalog = new DriverCatalog(drivers); const capacity = new CapacityCoordinator( - config(options.maxDevices), - new FakeSystemStats({ cpuCount: 8, freeRamBytes: 32 * gibibyte, totalRamBytes: 32 * gibibyte }), + createCapacityStrategy( + config(options.maxDevices).capacity, + new FakeSystemStats({ + cpuCount: 8, + freeRamBytes: 32 * gibibyte, + totalRamBytes: 32 * gibibyte, + }), + ), ); const lifecycle = new ManagedDeviceLifecycle(catalog, registry, decisions, claims, clock); const provisioner = new DeviceProvisioner({ catalog, clock, decisions, lifecycle, registry }); diff --git a/src/core/lease-acquisition-coordinator.ts b/src/core/lease-acquisition-coordinator.ts index ec0efda..68d2c3f 100644 --- a/src/core/lease-acquisition-coordinator.ts +++ b/src/core/lease-acquisition-coordinator.ts @@ -1,5 +1,5 @@ import type { EventBus } from "../bus/index.js"; -import type { CapacityReservation } from "./capacity-coordinator.js"; +import type { CapacityReservation } from "./capacity/index.js"; import { type AcquisitionPlan, type AcquisitionPlanner } from "./acquisition-planner.js"; import { type DeviceOperationClaim, diff --git a/src/core/lease-engine.test.ts b/src/core/lease-engine.test.ts index 435ed1f..73c7120 100644 --- a/src/core/lease-engine.test.ts +++ b/src/core/lease-engine.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { EventBus } from "../bus/index.js"; import { FakeClock, FakeSystemStats, MemoryFilesystem } from "../ports/index.js"; +import type { CapacityLimits, ResourceStrategyOptions } from "./capacity/index.js"; import { BootTimeoutError, type Config, @@ -33,12 +34,17 @@ function config(overrides: Partial = {}): Config { stalledTransition: { thresholdMultiplier: 3, minimumThresholdMs: 60_000 }, idle: { deleteAfterMs: 60_000, shutdownAfterMs: 10_000 }, lease: { detachedTtlMs: 100, heldTtlBackstopMs: 100, heartbeatIntervalMs: 25, ...overrides }, - limits: { - android: { maxDevices: 1, maxRunning: 1 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 1 + 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 1, maxRunning: 1 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 1 + 1, + }, + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, + }, }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, warmPool: { quarantine: { @@ -51,12 +57,18 @@ function config(overrides: Partial = {}): Config { }; } +/** Narrows the config's capacity block, which the harness always builds as `resource`. */ +function resourceOptions(source: Config): ResourceStrategyOptions { + if (source.capacity.strategy !== "resource") throw new Error("expected the resource strategy"); + return source.capacity.config; +} + async function createHarness( options: { readonly driver?: FakeDriver; readonly drivers?: readonly FakeDriver[]; readonly lease?: Partial; - readonly limits?: Config["limits"]; + readonly limits?: CapacityLimits; } = {}, ) { const clock = new FakeClock(1_000); @@ -74,7 +86,15 @@ async function createHarness( }); const baseConfig = config(options.lease); const engineConfig: Config = - options.limits === undefined ? baseConfig : { ...baseConfig, limits: options.limits }; + options.limits === undefined + ? baseConfig + : { + ...baseConfig, + capacity: { + strategy: "resource", + config: { ...resourceOptions(baseConfig), limits: options.limits }, + }, + }; const engine = new LeaseEngine({ clock, config: engineConfig, diff --git a/src/core/lease-engine.ts b/src/core/lease-engine.ts index 9db3b98..bcb2a74 100644 --- a/src/core/lease-engine.ts +++ b/src/core/lease-engine.ts @@ -1,8 +1,8 @@ import type { EventBus } from "../bus/index.js"; import type { Clock, IdGenerator, Logger, SystemStats } from "../ports/index.js"; -import type { RunningCapacity } from "./capacity.js"; +import type { RunningCapacity } from "./capacity/index.js"; import { AcquisitionPlanner } from "./acquisition-planner.js"; -import { CapacityCoordinator } from "./capacity-coordinator.js"; +import { CapacityCoordinator, createCapacityStrategy } from "./capacity/index.js"; import { CleanupExecutor, type CleanupActionExecutor } from "./cleanup-executor.js"; import type { Config } from "./config.js"; import type { Proposal } from "./cleanup/types.js"; @@ -88,7 +88,9 @@ export class LeaseEngine { readonly #warmPool: WarmPoolCoordinator; constructor(private readonly options: LeaseEngineOptions) { - this.#capacity = new CapacityCoordinator(options.config, options.systemStats); + this.#capacity = new CapacityCoordinator( + createCapacityStrategy(options.config.capacity, options.systemStats), + ); this.claimReader = this.#claims; this.#planner = new AcquisitionPlanner(this.#capacity, this.#claims); this.#drivers = new DriverCatalog(options.drivers); @@ -281,6 +283,12 @@ export class LeaseEngine { return this.#capacity.runningCapacity(this.#capacityDevices()); } + /** The managed-device ceiling the live strategy enforces, for status reporting. */ + // fallow-ignore-next-line unused-class-member -- reached through the CapacityReader port by DaemonServer. + deviceLimit(platform: Platform): number { + return this.#capacity.deviceLimit(platform); + } + /** Safely converges unleased running devices after startup reconciliation. */ async convergeRunningCapacity(): Promise { await this.#startup.converge(); diff --git a/src/core/lease-health-monitor.test.ts b/src/core/lease-health-monitor.test.ts index c19e5f6..c4291fb 100644 --- a/src/core/lease-health-monitor.test.ts +++ b/src/core/lease-health-monitor.test.ts @@ -38,13 +38,18 @@ function config(overrides: Partial = {}): Config { }, idle: { deleteAfterMs: 30_000, shutdownAfterMs: 10_000 }, lease: { detachedTtlMs: 100, heldTtlBackstopMs: 100, heartbeatIntervalMs: 25 }, - limits: { - android: { maxDevices: 2, maxRunning: 2 }, - ios: { maxDevices: 2, maxRunning: 2 }, - maxRunning: 4, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 2, maxRunning: 2 }, + ios: { maxDevices: 2, maxRunning: 2 }, + maxRunning: 4, + }, + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, + }, }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, stalledTransition: { thresholdMultiplier: 3, minimumThresholdMs: 60_000 }, }; } diff --git a/src/core/lease-ports.ts b/src/core/lease-ports.ts index 390afcf..e84d71c 100644 --- a/src/core/lease-ports.ts +++ b/src/core/lease-ports.ts @@ -1,4 +1,4 @@ -import type { RunningCapacity } from "./capacity.js"; +import type { CapacityPlatform, RunningCapacity } from "./capacity/index.js"; import type { LeaseRecord, Platform } from "./domain.js"; import type { DeviceRequest } from "./driver.js"; import type { PlatformCatalog } from "./driver-catalog.js"; @@ -26,6 +26,8 @@ export interface QueueControl { /** Read-only capacity view used by daemon status. */ export interface CapacityReader { readonly runningCapacity: RunningCapacity; + /** Managed-device ceiling, taken from the live strategy rather than from config. */ + deviceLimit(platform: CapacityPlatform): number; } /** Administrative lease expiry used by doctor reconciliation. */ diff --git a/src/core/nuke.test.ts b/src/core/nuke.test.ts index 5eb7a56..6101e7c 100644 --- a/src/core/nuke.test.ts +++ b/src/core/nuke.test.ts @@ -158,12 +158,17 @@ function config(): Config { stalledTransition: { thresholdMultiplier: 3, minimumThresholdMs: 60_000 }, idle: { deleteAfterMs: 10, shutdownAfterMs: 5 }, lease: { detachedTtlMs: 60_000, heldTtlBackstopMs: 60_000, heartbeatIntervalMs: 15_000 }, - limits: { - android: { maxDevices: 1, maxRunning: 1 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 1 + 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 1, maxRunning: 1 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 1 + 1, + }, + ramBudget: { androidBytesPerDevice: 1, iosBytesPerDevice: 1 }, + }, }, - ramBudget: { androidBytesPerDevice: 1, iosBytesPerDevice: 1 }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, warmPool: { quarantine: { diff --git a/src/core/reaper.test.ts b/src/core/reaper.test.ts index ace95db..4984d3b 100644 --- a/src/core/reaper.test.ts +++ b/src/core/reaper.test.ts @@ -57,12 +57,17 @@ function config(): Config { stalledTransition: { thresholdMultiplier: 3, minimumThresholdMs: 60_000 }, idle: { deleteAfterMs: 30_000, shutdownAfterMs: 10_000 }, lease: { detachedTtlMs: 100, heldTtlBackstopMs: 100, heartbeatIntervalMs: 25 }, - limits: { - android: { maxDevices: 1, maxRunning: 1 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 1 + 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 1, maxRunning: 1 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 1 + 1, + }, + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, + }, }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, warmPool: { quarantine: { diff --git a/src/core/startup-converger.test.ts b/src/core/startup-converger.test.ts index e3d3e6d..37a906e 100644 --- a/src/core/startup-converger.test.ts +++ b/src/core/startup-converger.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { RunningCapacity } from "./capacity.js"; +import type { RunningCapacity } from "./capacity/index.js"; import type { CleanupActionExecutor } from "./cleanup-executor.js"; import type { DeviceRecord, DeviceState, LeaseRecord, Platform } from "./domain.js"; import { SerializedDecision } from "./serialized-decision.js"; @@ -92,6 +92,7 @@ function createHarness( const quarantineRestore = { restore: vi.fn(() => void order.push("quarantine-restore")) }; const converger = new StartupConverger({ capacity: { + deviceLimit: () => limits.ios + limits.android, get runningCapacity() { return capacity(devices, limits); }, diff --git a/src/core/validation.ts b/src/core/validation.ts new file mode 100644 index 0000000..318473f --- /dev/null +++ b/src/core/validation.ts @@ -0,0 +1,104 @@ +/** + * Validator primitives shared by the config loader and by capacity strategy + * definitions, which validate their own options block. + */ + +export type Warn = (message: string) => void; + +export type Validator = (value: unknown, path: string, warn: Warn) => unknown; + +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +export function invalidValue(path: string, expected: string): ConfigError { + return new ConfigError(`Invalid config value for "${path}": expected ${expected}`); +} + +export function objectValidator(shape: Record): Validator { + return (value, path, warn) => { + const object = requireObject(value, path); + const result: Record = {}; + + for (const [key, child] of Object.entries(object)) { + const childPath = `${path}.${key}`; + const validator = shape[key]; + + if (validator === undefined) { + warn(`Unknown config key: "${childPath}"`); + continue; + } + + result[key] = validator(child, childPath, warn); + } + + return result; + }; +} + +export function requireObject(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw invalidValue(path, "an object"); + } + + return value as Record; +} + +export function positiveInteger(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + throw invalidValue(path, "a positive integer"); + } + + return value; +} + +export function nonNegativeNumber(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw invalidValue(path, "a non-negative number"); + } + + return value; +} + +export function positiveNumber(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw invalidValue(path, "a positive number"); + } + + return value; +} + +export function booleanValue(value: unknown, path: string): boolean { + if (typeof value !== "boolean") { + throw invalidValue(path, "a boolean"); + } + + return value; +} + +export function numberAtLeast(minimum: number): Validator { + return (value: unknown, path: string) => { + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) { + throw invalidValue(path, `a number >= ${minimum}`); + } + + return value; + }; +} + +export function stringUnion( + allowed: readonly Value[], + describe: (allowed: readonly Value[]) => string = (values) => + `one of ${values.map((value) => `"${value}"`).join(", ")}`, +): (value: unknown, path: string) => Value { + return (value, path) => { + if (typeof value !== "string" || !allowed.includes(value as Value)) { + throw invalidValue(path, describe(allowed)); + } + + return value as Value; + }; +} diff --git a/src/core/warm-pool-coordinator.test.ts b/src/core/warm-pool-coordinator.test.ts index 3fa43d1..fa1a630 100644 --- a/src/core/warm-pool-coordinator.test.ts +++ b/src/core/warm-pool-coordinator.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { EventBus } from "../bus/index.js"; import { FakeClock, FakeSystemStats } from "../ports/index.js"; -import { CapacityCoordinator } from "./capacity-coordinator.js"; +import { CapacityCoordinator, createCapacityStrategy } from "./capacity/index.js"; import type { Config } from "./config.js"; import type { DeviceRecord, DeviceSpec, LeaseRecord } from "./domain.js"; import { FakeDriver } from "./fake-driver.js"; @@ -36,12 +36,17 @@ const config: Config = { }, }, lease: { detachedTtlMs: 100, heldTtlBackstopMs: 100, heartbeatIntervalMs: 25 }, - limits: { - android: { maxDevices: 2, maxRunning: 1 }, - ios: { maxDevices: 2, maxRunning: 1 }, - maxRunning: 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 2, maxRunning: 1 }, + ios: { maxDevices: 2, maxRunning: 1 }, + maxRunning: 1, + }, + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, + }, }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, }; @@ -96,12 +101,14 @@ class TestRegistry { function capacity(): CapacityCoordinator { return new CapacityCoordinator( - config, - new FakeSystemStats({ - cpuCount: 8, - freeRamBytes: 32 * gibibyte, - totalRamBytes: 32 * gibibyte, - }), + createCapacityStrategy( + config.capacity, + new FakeSystemStats({ + cpuCount: 8, + freeRamBytes: 32 * gibibyte, + totalRamBytes: 32 * gibibyte, + }), + ), ); } diff --git a/src/core/warm-pool-coordinator.ts b/src/core/warm-pool-coordinator.ts index c5a1ac8..064577c 100644 --- a/src/core/warm-pool-coordinator.ts +++ b/src/core/warm-pool-coordinator.ts @@ -1,6 +1,6 @@ import type { EventBus } from "../bus/index.js"; import type { Clock } from "../ports/index.js"; -import type { CapacityDecision, CapacityDevice, RunningCapacity } from "./capacity.js"; +import type { CapacityDecision, CapacityDevice, RunningCapacity } from "./capacity/index.js"; import { type DeviceRecord, type DeviceSpec, diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 336f144..dc823b3 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -1280,12 +1280,17 @@ function testConfig(leaseOverrides?: Partial): Config { heartbeatIntervalMs: 5_000, ...leaseOverrides, }, - limits: { - android: { maxDevices: 1, maxRunning: 1 }, - ios: { maxDevices: 1, maxRunning: 1 }, - maxRunning: 1 + 1, + capacity: { + strategy: "resource", + config: { + limits: { + android: { maxDevices: 1, maxRunning: 1 }, + ios: { maxDevices: 1, maxRunning: 1 }, + maxRunning: 1 + 1, + }, + ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, + }, }, - ramBudget: { androidBytesPerDevice: 4 * gibibyte, iosBytesPerDevice: gibibyte }, log: { level: "info", rotateBytes: 5 * 1024 * 1024 }, warmPool: { quarantine: { diff --git a/src/daemon/server.ts b/src/daemon/server.ts index e17c353..6ddf38e 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -601,7 +601,7 @@ export class DaemonServer { (["ios", "android"] as const).map((platform) => [ platform, { - limit: this.options.config.limits[platform].maxDevices, + limit: this.options.capacity.deviceLimit(platform), ...running[platform], warm: warmDevices.filter((device) => device.spec.platform === platform).length, used: snapshot.devices.filter(