From a78f76b3d8eaf87d2f33068a24d0aa419632b08c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 13:34:53 +0200 Subject: [PATCH 01/13] test: optional modules load through one host and are turned on under capabilities (#595) --- src/contract/protocol.test.ts | 7 + src/contract/protocol.ts | 3 + src/core/config-capabilities.test.ts | 125 ++++ src/core/config.ts | 9 + src/core/index.ts | 1 + src/daemon/main.ts | 3 + src/daemon/module-shapes.test.ts | 32 + src/daemon/optional-modules.test.ts | 879 +++++++++++++++++++++++++ src/daemon/optional-modules.test.ts-E | 882 ++++++++++++++++++++++++++ src/http/core-routes.test.ts | 61 ++ src/lint-rules.test.ts | 75 +++ src/module-registry/index.ts | 3 + src/modules/conformance.test.ts | 126 ++++ src/modules/index.ts | 149 +++++ src/modules/testing.ts | 127 ++++ src/modules/testing.ts-E | 131 ++++ 16 files changed, 2613 insertions(+) create mode 100644 src/core/config-capabilities.test.ts create mode 100644 src/daemon/module-shapes.test.ts create mode 100644 src/daemon/optional-modules.test.ts create mode 100644 src/daemon/optional-modules.test.ts-E create mode 100644 src/http/core-routes.test.ts create mode 100644 src/module-registry/index.ts create mode 100644 src/modules/conformance.test.ts create mode 100644 src/modules/index.ts create mode 100644 src/modules/testing.ts create mode 100644 src/modules/testing.ts-E diff --git a/src/contract/protocol.test.ts b/src/contract/protocol.test.ts index 32b05af41..f2b90eef1 100644 --- a/src/contract/protocol.test.ts +++ b/src/contract/protocol.test.ts @@ -4,6 +4,7 @@ import { helloRequestSchema, negotiateProtocolVersion, normalizeProtocolVersion, + PROTOCOL_VERSION_RANGE, } from "./protocol.js"; describe("negotiateProtocolVersion", () => { @@ -44,3 +45,9 @@ describe("helloRequestSchema", () => { expect(() => helloRequestSchema.parse({ clientVersion: "1.0.0" })).toThrow(); }); }); + +describe("PROTOCOL_VERSION_RANGE", () => { + it("is one above the base branch's, for the capabilities block config.get now returns", () => { + expect(PROTOCOL_VERSION_RANGE).toEqual({ max: 38, min: 38 }); + }); +}); diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index 158b4f210..0a3668cb9 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -77,3 +77,6 @@ export const helloReplySchema = z.object({ role: z.enum(["agent", "admin"]), principal: z.string(), }); + +/** The first path segment of every route the HTTP app registers itself; a module's name may not start with one. */ +export const CORE_ROUTE_SEGMENTS: readonly string[] = []; diff --git a/src/core/config-capabilities.test.ts b/src/core/config-capabilities.test.ts new file mode 100644 index 000000000..080c825b8 --- /dev/null +++ b/src/core/config-capabilities.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; + +import { configSchema } from "../contract/index.js"; +import { FakeSystemStats, MemoryFilesystem } from "../ports/index.js"; +import { loadConfig, type Config, type ModuleConfigEntry } from "./index.js"; +import { objectValidator, stringValue } from "./validation.js"; + +const configPath = "/home/agent/.simlock/config.json"; + +function entry(overrides: Partial = {}): ModuleConfigEntry { + return { + defaults: { greeting: "hello" }, + gatewayKeys: [], + name: "alpha", + validator: objectValidator({ greeting: stringValue }), + ...overrides, + }; +} + +async function load( + contents: unknown, + modules: readonly ModuleConfigEntry[] = [entry()], + warnings: string[] = [], +) { + const filesystem = new MemoryFilesystem(); + await filesystem.mkdirp("/home/agent/.simlock"); + await filesystem.writeFileAtomic(configPath, JSON.stringify(contents)); + const config = await loadConfig({ + configPath, + filesystem, + modules, + systemStats: new FakeSystemStats({ cpuCount: 8, totalRamBytes: 32 * 1024 ** 3 }), + warn: (message) => warnings.push(message), + }); + return config as Config & { + readonly capabilities: Readonly>>; + }; +} + +describe("loadConfig with optional modules", () => { + it("fills a module's section from its defaults, with enabled false, when the file has none", async () => { + const config = await load({}); + + expect(config.capabilities).toEqual({ alpha: { enabled: false, greeting: "hello" } }); + }); + + it('warns Unknown config key: "capabilities.nope" for a name no module has, and drops it', async () => { + const warnings: string[] = []; + + const config = await load( + { capabilities: { alpha: { enabled: true }, nope: {} } }, + [entry()], + warnings, + ); + + expect(warnings).toEqual(['Unknown config key: "capabilities.nope"']); + expect(Object.keys(config.capabilities)).toEqual(["alpha"]); + }); + + it("validates a module's section with its validator, and warns about an unknown key inside it but not about enabled", async () => { + const warnings: string[] = []; + + const config = await load( + { capabilities: { alpha: { enabled: true, extra: 1, greeting: "hi" } } }, + [entry()], + warnings, + ); + + expect(warnings).toEqual(['Unknown config key: "capabilities.alpha.extra"']); + expect(config.capabilities["alpha"]).toEqual({ enabled: true, greeting: "hi" }); + await expect(load({ capabilities: { alpha: { greeting: 3 } } })).rejects.toThrow( + "capabilities.alpha.greeting", + ); + }); + + it("refuses a non-boolean capabilities..enabled", async () => { + await expect(load({ capabilities: { alpha: { enabled: true } } })).resolves.toBeDefined(); + + await expect(load({ capabilities: { alpha: { enabled: "yes" } } })).rejects.toThrow( + "capabilities.alpha.enabled", + ); + }); + + it("runs a module's check against the merged config, and fails the load when it throws", async () => { + const seen = vi.fn(); + const check = (section: { greeting: string }, config: Config) => { + seen(config.mode); + if (section.greeting === "bad") throw new Error("greeting is bad"); + }; + const checked = entry({ check: check as NonNullable }); + await load({ capabilities: { alpha: { greeting: "good" } } }, [checked]); + + await expect(load({ capabilities: { alpha: { greeting: "bad" } } }, [checked])).rejects.toThrow( + "greeting is bad", + ); + + expect(seen).toHaveBeenCalledWith("worker"); + }); + + it("on a gateway, reads enabled without a warning, and warns about a key outside gatewayKeys, naming capabilities..", async () => { + const contents = { + capabilities: { alpha: { enabled: true, greeting: "hi" } }, + mode: "gateway", + }; + const closed: string[] = []; + const open: string[] = []; + + const config = await load(contents, [entry()], closed); + await load(contents, [entry({ gatewayKeys: ["greeting"] })], open); + + expect(config.capabilities["alpha"]?.["enabled"]).toBe(true); + expect(closed).toEqual([ + 'Ignoring "capabilities.alpha.greeting": it configures a worker, and this daemon runs in gateway mode.', + ]); + expect(open).toEqual([]); + }); + + it("config.get's schema keeps capabilities with each module's section", async () => { + const config = await load({ capabilities: { alpha: { enabled: true } } }); + + const parsed = configSchema.parse(config) as unknown as { capabilities?: unknown }; + + expect(parsed.capabilities).toEqual({ alpha: { enabled: true, greeting: "hello" } }); + }); +}); diff --git a/src/core/config.ts b/src/core/config.ts index b3a6a3beb..288fb5675 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -361,7 +361,16 @@ export interface LegacyCapacityOverrides { export type ConfigOverrides = DeepPartial & LegacyCapacityOverrides; +export interface ModuleConfigEntry { + readonly name: string; + readonly validator: Validator; + readonly defaults: unknown; + readonly gatewayKeys: readonly string[]; + readonly check?: (section: never, config: Config) => void; +} + export interface LoadConfigOptions { + readonly modules?: readonly ModuleConfigEntry[]; readonly filesystem: Filesystem; readonly systemStats: SystemStats; readonly configPath?: string; diff --git a/src/core/index.ts b/src/core/index.ts index 0f1f9e162..f09742281 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -7,6 +7,7 @@ export { type DownloadPolicy, effectiveAllowDownload, loadConfig, + type ModuleConfigEntry, REDACTED_VALUE, redactConfig, } from "./config.js"; diff --git a/src/daemon/main.ts b/src/daemon/main.ts index daaba518e..ec3738ee8 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -117,6 +117,7 @@ import { WebSocketUplinkListenerFactory, } from "../ports/uplink-websocket.js"; import { classifyError } from "./error-code.js"; +import type { OptionalModuleDefinition } from "../modules/index.js"; import { DaemonServer } from "./server.js"; import { DaemonEndpointHost } from "./connection-host.js"; import { AdminSecretManager } from "./admin-secret.js"; @@ -138,6 +139,8 @@ export interface StartDaemonOptions { readonly gatewayRelay?: (relay: GatewayRelay) => void; /** why: test-only, hands over the gateway's uplink listener, to close one worker's socket. */ readonly gatewayUplinks?: (uplinks: WebSocketUplinkListenerFactory) => void; + /** why: test-only, replaces the registry of optional modules. */ + readonly optionalModules?: readonly OptionalModuleDefinition[]; readonly relayTargets?: RelayTargets; readonly prerequisiteChecks?: readonly PrerequisiteCheck[]; readonly filesystem?: Filesystem; diff --git a/src/daemon/module-shapes.test.ts b/src/daemon/module-shapes.test.ts new file mode 100644 index 000000000..9241f4d13 --- /dev/null +++ b/src/daemon/module-shapes.test.ts @@ -0,0 +1,32 @@ +import { describe, expectTypeOf, it } from "vitest"; + +import type { EndedLease, LeaseEndParticipant } from "../leasing/index.js"; +import type { HttpMount } from "../http/app.js"; +import type { HttpDispatch } from "../http/dispatcher-session.js"; +import type { + ModuleCapabilitySource, + ModuleDispatch, + ModuleEndedLease, + ModuleLeaseEndParticipant, + ModuleMount, + ModuleRelayTarget, +} from "../modules/index.js"; +import type { CapabilitySource } from "./dispatcher.js"; +import type { RelayTarget } from "./relay.js"; + +describe("the shapes src/modules declares by shape", () => { + it("each by-shape type in src/modules and its original are assignable both ways", () => { + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + }); +}); diff --git a/src/daemon/optional-modules.test.ts b/src/daemon/optional-modules.test.ts new file mode 100644 index 000000000..687616eca --- /dev/null +++ b/src/daemon/optional-modules.test.ts @@ -0,0 +1,879 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FakeAgentDevice } from "../agent-device/testing.js"; +import { FakeDriver } from "../core/testing.js"; +import type { OptionalModuleDefinition, WorkerHooks } from "../modules/index.js"; +import { fakeModule, type FakeModule } from "../modules/testing.js"; +import { FakeClock, JsonLinesLogger, MemoryFilesystem, MemoryLogSink } from "../ports/index.js"; +import { startDaemon, type StartDaemonOptions } from "./main.js"; +import type { DaemonServer } from "./server.js"; +import { openRelay, openUplink, relayInput, settleWithin } from "./test-relay-support.js"; + +const admin = { + manageEventSubscription: () => undefined, + principal: "test-operator", + role: "admin", +} as const; +const agent = { + manageEventSubscription: () => undefined, + principal: "agent-1", + role: "agent", +} as const; +const ios = { + model: "iPhone 16", + osVersion: "26.5", + platform: "ios", + requesterId: "agent-1", +} as const; + +const daemons: DaemonServer[] = []; +const directories: string[] = []; +const servers: Server[] = []; + +afterEach(async () => { + await Promise.all(daemons.splice(0).map((daemon) => daemon.stop("test").catch(() => undefined))); + await Promise.all(servers.splice(0).map((server) => new Promise((done) => server.close(done)))); + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +async function freeLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + await new Promise((resolve) => server.close(() => resolve())); + if (typeof address !== "object" || address === null) throw new Error("no port bound"); + return address.port; +} + +class NotingDriver extends FakeDriver { + constructor( + options: ConstructorParameters[0], + readonly notes: string[], + ) { + super(options); + } + + override async reclaim(...args: Parameters) { + this.notes.push("driver reclaim"); + return super.reclaim(...args); + } + + async dispose(): Promise { + this.notes.push("driver dispose"); + } +} + +interface Rig { + readonly daemon: DaemonServer; + readonly clock: FakeClock; + readonly sink: MemoryLogSink; + readonly directory: string; + readonly notes: string[]; + readonly origin: string | undefined; + readonly driver: NotingDriver; +} + +interface RigOptions { + readonly modules: readonly OptionalModuleDefinition[]; + readonly on?: readonly string[]; + readonly http?: boolean; + readonly extra?: Partial; + readonly overrides?: Record; + readonly notes?: string[]; +} + +function definitions(...modules: FakeModule[]): OptionalModuleDefinition[] { + return modules.map((module) => module.definition as OptionalModuleDefinition); +} + +async function prepare(options: RigOptions) { + const directory = await mkdtemp(join(tmpdir(), "simlock-modules-")); + directories.push(directory); + const clock = new FakeClock(1_000); + const sink = new MemoryLogSink(); + const notes = options.notes ?? []; + const driver = new NotingDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }, notes); + const port = options.http === true ? await freeLoopbackPort() : undefined; + const startOptions = { + clock, + configOverrides: { + capabilities: Object.fromEntries((options.on ?? []).map((name) => [name, { enabled: true }])), + ...(port === undefined ? {} : { http: { enabled: true, host: "127.0.0.1", port } }), + ...options.overrides, + }, + dataDirectory: directory, + drivers: [driver], + filesystem: new MemoryFilesystem(), + hostLoadProbe: null, + logger: new JsonLinesLogger({ clock, level: "debug", sink }), + optionalModules: options.modules, + socketPath: join(directory, "daemon.sock"), + statePath: join(directory, "state.json"), + version: "1.2.3", + ...options.extra, + } as StartDaemonOptions; + return { + clock, + directory, + driver, + notes, + origin: port === undefined ? undefined : `http://127.0.0.1:${String(port)}`, + sink, + startOptions, + }; +} + +async function start(options: RigOptions): Promise { + const prepared = await prepare(options); + const daemon = await startDaemon(prepared.startOptions); + daemons.push(daemon); + return { ...prepared, daemon }; +} + +async function agentHeaders(daemon: DaemonServer): Promise> { + const { secret } = await daemon.dispatch("token.create", { label: "a", role: "agent" }, admin); + return { authorization: `Bearer ${secret}` }; +} + +async function capabilities(daemon: DaemonServer) { + const status = (await daemon.dispatch("status.get", {}, agent)) as { + readonly capabilities?: readonly { name: string; state: string; reason?: string }[]; + }; + return status.capabilities ?? []; +} + +function recordsOf(sink: MemoryLogSink, message: string) { + return sink.records.filter((record) => record.message === message); +} + +async function leaseAndRelease(daemon: DaemonServer): Promise { + const grant = (await daemon.dispatch("lease.request", ios, agent)) as { lease: { id: string } }; + await daemon.dispatch("lease.release", { leaseId: grant.lease.id }, agent); + return grant.lease.id; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe("optional modules: turning on", () => { + it("a module with no capabilities section, or with enabled false, is reported off in status.get, and its loadWorker is never called", async () => { + const bare = fakeModule({ name: "alpha" }); + const disabled = fakeModule({ name: "bravo" }); + const { daemon } = await start({ + modules: definitions(bare, disabled), + overrides: { capabilities: { bravo: { enabled: false } } }, + }); + + expect(await capabilities(daemon)).toEqual( + expect.arrayContaining([ + { name: "alpha", state: "off" }, + { name: "bravo", state: "off" }, + ]), + ); + expect({ alpha: bare.loads, bravo: disabled.loads }).toEqual({ alpha: 0, bravo: 0 }); + }); + + it("a module turned on is loaded once and reports the state its capability source answers", async () => { + const module = fakeModule({ name: "alpha", state: "busy" }); + const { daemon } = await start({ modules: definitions(module), on: ["alpha"] }); + + expect(await capabilities(daemon)).toContainEqual({ name: "alpha", state: "busy" }); + expect({ loads: module.loads, creates: module.creates }).toEqual({ creates: 1, loads: 1 }); + }); + + it("the capability list is agent-device's entry first, then registered modules in registry order", async () => { + const { daemon } = await start({ + extra: { agentDeviceUpstream: new FakeAgentDevice() }, + modules: definitions(fakeModule({ name: "zulu" }), fakeModule({ name: "alpha" })), + on: ["zulu", "alpha"], + overrides: { agentDevice: { enabled: true } }, + }); + + expect((await capabilities(daemon)).map((entry) => entry.name)).toEqual([ + "agent-device", + "zulu", + "alpha", + ]); + }); + + it("config.get returns capabilities with each module's section", async () => { + const { daemon } = await start({ + modules: definitions(fakeModule({ name: "alpha" })), + on: ["alpha"], + }); + + const config = (await daemon.dispatch("config.get", {}, admin)) as { + readonly capabilities?: unknown; + }; + + expect(config.capabilities).toEqual({ alpha: { enabled: true, greeting: "hello" } }); + }); +}); + +describe("optional modules: HTTP routes", () => { + const handler = (request: Request, identity: { readonly role: string }) => + Promise.resolve(Response.json({ path: new URL(request.url).pathname, role: identity.role })); + + it("a module turned on serves /v1/ and /v1//* through its handler, behind agent auth", async () => { + const { daemon, origin } = await start({ + http: true, + modules: definitions(fakeModule({ mount: () => handler, name: "alpha" })), + on: ["alpha"], + }); + const headers = await agentHeaders(daemon); + + const root = await fetch(`${origin}/v1/alpha`, { headers }); + const deep = await fetch(`${origin}/v1/alpha/deep/x`, { headers }); + const anonymous = await fetch(`${origin}/v1/alpha`); + + expect(await root.json()).toEqual({ path: "/v1/alpha", role: "agent" }); + expect(await deep.json()).toEqual({ path: "/v1/alpha/deep/x", role: "agent" }); + expect(anonymous.status).toBe(401); + }); + + it("a module named like a core route's first segment (status) fails the start, with HTTP off too", async () => { + const accepted = await start({ modules: definitions(fakeModule({ name: "stats" })) }); + expect(accepted.daemon).toBeDefined(); + + await expect(start({ modules: definitions(fakeModule({ name: "status" })) })).rejects.toThrow( + "status", + ); + }); + + it("with agent-device on and a fake module registered, both modules' routes and relay targets answer, and both capabilities are listed", async () => { + const seen: string[] = []; + const { daemon, origin } = await start({ + extra: { agentDeviceUpstream: new FakeAgentDevice() }, + http: true, + modules: definitions( + fakeModule({ + mount: () => handler, + name: "alpha", + relayTarget: (_request, tenant) => { + seen.push(tenant); + return Promise.resolve(new Response("alpha")); + }, + }), + ), + on: ["alpha"], + overrides: { agentDevice: { enabled: true } }, + }); + const headers = await agentHeaders(daemon); + const uplink = await openUplink(daemon); + + const alphaRoute = await fetch(`${origin}/v1/alpha/ping`, { headers }); + const agentDeviceRoute = await fetch(`${origin}/v1/agent-device/health`, { headers }); + const alphaRelay = await openRelay(uplink, relayInput({ target: "alpha" })); + const agentDeviceRelay = await settleWithin( + (await openRelay(uplink, relayInput({ target: "agent-device" }))).result, + ); + const nobodyRelay = await settleWithin( + (await openRelay(uplink, relayInput({ target: "nobody" }))).result, + ); + + expect([alphaRoute.status, agentDeviceRoute.status]).toEqual([200, 200]); + expect(seen).toEqual(["gw.g1.alice"]); + expect((await alphaRelay.body()).state).toBe("fulfilled"); + expect(nobodyRelay).toMatchObject({ code: "RELAY_TARGET_MISSING", state: "rejected" }); + expect(agentDeviceRelay).not.toMatchObject({ code: "RELAY_TARGET_MISSING" }); + expect((await capabilities(daemon)).map((entry) => entry.name)).toEqual([ + "agent-device", + "alpha", + ]); + }); +}); + +describe("optional modules: relay targets", () => { + it("a relay request to a module's name reaches its relay target with the tenant and lease id", async () => { + const calls: { tenant: string; leaseId: string | undefined }[] = []; + const { daemon } = await start({ + modules: definitions( + fakeModule({ + name: "alpha", + relayTarget: (_request, tenant, leaseId) => { + calls.push({ leaseId, tenant }); + return Promise.resolve(new Response("from alpha")); + }, + }), + ), + on: ["alpha"], + }); + const uplink = await openUplink(daemon); + + const relay = await openRelay( + uplink, + relayInput({ leaseId: "lease-9", target: "alpha", tenant: "gw.g1.bob" }), + ); + const body = await relay.body(); + + expect(calls).toEqual([{ leaseId: "lease-9", tenant: "gw.g1.bob" }]); + expect(body.state === "fulfilled" ? Buffer.from(body.value).toString() : body.state).toBe( + "from alpha", + ); + }); + + it("a test's relayTargets option still wins over a module's relay target", async () => { + const { daemon } = await start({ + extra: { relayTargets: { alpha: () => Promise.resolve(new Response("from the test")) } }, + modules: definitions( + fakeModule({ + name: "alpha", + relayTarget: () => Promise.resolve(new Response("from alpha")), + }), + ), + on: ["alpha"], + }); + const uplink = await openUplink(daemon); + + const body = await (await openRelay(uplink, relayInput({ target: "alpha" }))).body(); + + expect(body.state === "fulfilled" ? Buffer.from(body.value).toString() : body.state).toBe( + "from the test", + ); + }); +}); + +describe("optional modules: doctor findings", () => { + it("a fake module's finding reaches doctor.run as module-unavailable with module, message and remedy; a second module whose findings() throws loses only its own", async () => { + const { daemon, sink } = await start({ + modules: definitions( + fakeModule({ + findings: () => [{ message: "it is down", remedy: "bring it up" }], + name: "alpha", + }), + fakeModule({ + findings: () => { + throw new Error("boom"); + }, + name: "bravo", + }), + ), + on: ["alpha", "bravo"], + }); + + const report = (await daemon.dispatch("doctor.run", {}, admin)) as { + readonly findings: readonly { readonly kind: string }[]; + }; + + expect(report.findings).toContainEqual({ + kind: "module-unavailable", + message: "it is down", + module: "alpha", + remedy: "bring it up", + }); + expect(report.findings.filter((finding) => finding.kind === "module-unavailable")).toHaveLength( + 1, + ); + expect( + recordsOf(sink, "Could not read optional module findings").map((record) => record.fields), + ).toEqual([{ message: "boom", module: "bravo" }]); + }); +}); + +describe("optional modules: a start that cannot go on", () => { + const cases: readonly { + readonly title: string; + readonly good: () => FakeModule[]; + readonly bad: () => FakeModule[]; + readonly names: readonly string[]; + readonly overrides?: Record; + }[] = [ + { + bad: () => [fakeModule({ capabilityName: "beta", name: "alpha" })], + good: () => [fakeModule({ name: "alpha" })], + names: ["alpha", "beta"], + title: "a capability name that differs from the definition's", + }, + { + bad: () => [fakeModule({ name: "alpha" }), fakeModule({ name: "alpha" })], + good: () => [fakeModule({ name: "alpha" }), fakeModule({ name: "bravo" })], + names: ["alpha"], + title: "two definitions with one name", + }, + { + bad: () => [fakeModule({ name: "agent-device" })], + good: () => [fakeModule({ name: "agent-devices" })], + names: ["agent-device"], + title: "a definition named agent-device while it is wired by hand", + }, + { + bad: () => [fakeModule({ name: "Bad_Name" })], + good: () => [fakeModule({ name: "good-name" })], + names: ["Bad_Name"], + title: "a name that fails the capability name check", + }, + { + bad: () => Array.from({ length: 16 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), + good: () => Array.from({ length: 15 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), + names: ["16"], + title: "16 fake modules plus agent-device wired by hand (17 capabilities)", + }, + { + bad: () => [fakeModule({ loadError: new Error("no such file"), name: "alpha" })], + good: () => [fakeModule({ name: "alpha" })], + names: ["alpha", "no such file"], + title: "a loadWorker() that throws", + }, + ]; + + it.each(cases)("the start fails, naming the module, for $title", async ({ bad, good, names }) => { + const turnOn = (modules: FakeModule[]) => modules.map((m) => m.definition.name); + const goodModules = good(); + await start({ modules: definitions(...goodModules), on: turnOn(goodModules) }); + + const failure = await start({ modules: definitions(...bad()), on: turnOn(bad()) }).then( + () => undefined, + (error: unknown) => error as Error, + ); + + expect(failure).toBeInstanceOf(Error); + for (const name of names) expect(failure?.message).toContain(name); + }); + + it("a module that calls ctx.cancelPending in createWorker fails the start, naming the module", async () => { + const quiet = fakeModule({ name: "alpha" }); + await start({ modules: definitions(quiet), on: ["alpha"] }); + + const failure = await start({ + modules: definitions( + fakeModule({ name: "alpha", onCreate: (ctx) => void ctx.cancelPending("agent-1") }), + ), + on: ["alpha"], + }).then( + () => undefined, + (error: unknown) => error as Error, + ); + + expect(failure).toBeInstanceOf(Error); + expect(failure?.message).toContain("alpha"); + expect(failure?.message).toContain("leasing is not ready yet"); + }); + + it("when the second of two modules throws in createWorker, the first module's afterSocketClaimed never runs", async () => { + const claimed = vi.fn(); + const first = fakeModule({ hooks: { afterSocketClaimed: claimed }, name: "alpha" }); + const failure = await start({ + modules: definitions( + first, + fakeModule({ + name: "bravo", + onCreate: () => { + throw new Error("bravo broke"); + }, + }), + ), + on: ["alpha", "bravo"], + }).then( + () => undefined, + (error: unknown) => error as Error, + ); + await sleep(50); + + expect(failure).toBeInstanceOf(Error); + expect(failure?.message).toContain("bravo"); + expect({ claimed: claimed.mock.calls.length, created: first.creates }).toEqual({ + claimed: 0, + created: 1, + }); + }); + + it("when the HTTP gateway fails to bind after the socket is claimed, the daemon stops and each started module's beforeDrain and beforeDriversDispose run", async () => { + const taken = createServer(); + servers.push(taken); + await new Promise((resolve) => taken.listen(0, "127.0.0.1", resolve)); + const { port } = taken.address() as { port: number }; + const notes: string[] = []; + const hooks: WorkerHooks = { + beforeDrain: () => { + notes.push("drain"); + return Promise.resolve(); + }, + beforeDriversDispose: () => { + notes.push("dispose"); + return Promise.resolve(); + }, + }; + + const outcome = await prepare({ + modules: definitions(fakeModule({ hooks, name: "alpha" })), + notes, + on: ["alpha"], + overrides: { http: { enabled: true, host: "127.0.0.1", port } }, + }).then((prepared) => + startDaemon(prepared.startOptions).then( + () => "started", + () => "rejected", + ), + ); + await vi.waitFor(() => expect(notes).toContain("dispose")); + + expect(outcome).toBe("rejected"); + expect(notes.filter((note) => note === "drain" || note === "dispose")).toEqual([ + "drain", + "dispose", + ]); + }); +}); + +describe("optional modules: the context a module receives", () => { + it("has a logger named after the module, and events.subscribe delivers a bus event", async () => { + const delivered: string[] = []; + const { daemon, sink } = await start({ + modules: definitions( + fakeModule({ + name: "alpha", + onCreate: (ctx) => { + ctx.logger.info("alpha says hello"); + ctx.events.subscribe("lease.released", (envelope) => { + delivered.push(envelope.payload.leaseId); + }); + }, + }), + ), + on: ["alpha"], + }); + + const leaseId = await leaseAndRelease(daemon); + + expect(recordsOf(sink, "alpha says hello").map((record) => record.module)).toEqual([ + "daemon.alpha", + ]); + expect(delivered).toEqual([leaseId]); + }); +}); + +describe("optional modules: afterSocketClaimed", () => { + it("runs once after the socket is claimed, and the start does not wait for it", async () => { + let socketPath = ""; + const seen: boolean[] = []; + const never = new Promise(() => undefined); + const prepared = await prepare({ + modules: definitions( + fakeModule({ + hooks: { + afterSocketClaimed: () => { + seen.push(existsSync(socketPath)); + return never; + }, + }, + name: "alpha", + }), + ), + on: ["alpha"], + }); + socketPath = join(prepared.directory, "daemon.sock"); + + const started = await settleWithin(startDaemon(prepared.startOptions), 5_000); + if (started.state === "fulfilled") daemons.push(started.value); + await sleep(50); + + expect(started.state).toBe("fulfilled"); + expect(seen).toEqual([true]); + }); + + it("never runs in a daemon that lost the socket race", async () => { + const claimed = vi.fn(); + const filesystem = new MemoryFilesystem(); + const directory = await mkdtemp(join(tmpdir(), "simlock-modules-race-")); + directories.push(directory); + const options = (modules: readonly OptionalModuleDefinition[]) => + ({ + clock: new FakeClock(1_000), + configOverrides: { capabilities: { alpha: { enabled: true } } }, + dataDirectory: directory, + drivers: [ + new FakeDriver({ + availableOsVersions: ["26.5"], + clock: new FakeClock(1_000), + platform: "ios", + }), + ], + filesystem, + logger: new JsonLinesLogger({ + clock: new FakeClock(1_000), + level: "debug", + sink: new MemoryLogSink(), + }), + optionalModules: modules, + statePath: join(directory, "state.json"), + version: "1.2.3", + }) as StartDaemonOptions; + const first = await startDaemon(options([])); + daemons.push(first); + + const loser = fakeModule({ hooks: { afterSocketClaimed: claimed }, name: "alpha" }); + const second = await startDaemon(options(definitions(loser))).then( + () => "started", + () => "rejected", + ); + await sleep(50); + + expect({ claimed: claimed.mock.calls.length, created: loser.creates, second }).toEqual({ + claimed: 0, + created: 1, + second: "rejected", + }); + }); + + it.each([ + [ + "throws", + () => { + throw new Error("hook broke"); + }, + ], + ["rejects", () => Promise.reject(new Error("hook broke"))], + ] as const)( + "an afterSocketClaimed that %s is logged as failed, and the daemon still starts", + async (_how, hook) => { + const { sink } = await start({ + modules: definitions(fakeModule({ hooks: { afterSocketClaimed: hook }, name: "alpha" })), + on: ["alpha"], + }); + + await vi.waitFor(() => + expect( + recordsOf(sink, "Optional module hook failed").map((record) => record.fields), + ).toEqual([{ hook: "afterSocketClaimed", message: "hook broke", module: "alpha" }]), + ); + }, + ); +}); + +describe("optional modules: beforeDeviceReclaim", () => { + it("a release reclaims the device when no turned-on module has beforeDeviceReclaim", async () => { + const notes: string[] = []; + const { daemon } = await start({ + modules: definitions(fakeModule({ name: "alpha" })), + notes, + on: ["alpha"], + }); + + await leaseAndRelease(daemon); + + await vi.waitFor(() => expect(notes).toContain("driver reclaim")); + }); + + it("beforeDeviceReclaim runs after a release commits and before the device's reclaim starts", async () => { + const notes: string[] = []; + let live: DaemonServer | undefined; + const { daemon } = await start({ + modules: definitions( + fakeModule({ + hooks: { + beforeDeviceReclaim: async (lease) => { + const listed = (await live?.dispatch("lease.list", {}, admin)) as { + leases: { id: string }[]; + }; + notes.push( + listed.leases.some((entry) => entry.id === lease.leaseId) + ? "lease still listed" + : "lease gone", + ); + await sleep(30); + notes.push("hook done"); + }, + }, + name: "alpha", + }), + ), + notes, + on: ["alpha"], + }); + live = daemon; + + await leaseAndRelease(daemon); + await vi.waitFor(() => expect(notes).toContain("driver reclaim")); + + expect(notes).toEqual(["lease gone", "hook done", "driver reclaim"]); + }); +}); + +describe("optional modules: stop hooks", () => { + it("beforeDrain finishes before the HTTP gateway stops and before leasing's settle starts", async () => { + let origin: string | undefined; + let moduleLog: { info(message: string): void } | undefined; + const notes: string[] = []; + const rig = await start({ + http: true, + modules: definitions( + fakeModule({ + hooks: { + beforeDrain: async () => { + await sleep(50); + const health = await fetch(`${origin}/v1/healthz`).then( + (response) => String(response.status), + () => "refused", + ); + notes.push(`gateway ${health}`); + moduleLog?.info("drain done"); + }, + }, + name: "alpha", + onCreate: (ctx) => { + moduleLog = ctx.logger; + }, + }), + ), + on: ["alpha"], + }); + origin = rig.origin; + + await rig.daemon.stop("test"); + + const messages = rig.sink.records.map((record) => record.message); + expect(notes).toEqual(["gateway 200"]); + expect(messages.indexOf("drain done")).toBeGreaterThanOrEqual(0); + expect(messages.indexOf("drain done")).toBeLessThan(messages.indexOf("Daemon stopping")); + }); + + it("beforeDriversDispose finishes before any driver's dispose starts", async () => { + const notes: string[] = []; + const { daemon } = await start({ + modules: definitions( + fakeModule({ + hooks: { + beforeDriversDispose: async () => { + await sleep(30); + notes.push("hook done"); + }, + }, + name: "alpha", + }), + ), + notes, + on: ["alpha"], + }); + + await daemon.stop("test"); + + expect(notes).toEqual(["hook done", "driver dispose"]); + }); + + it("with two modules whose beforeDrain hangs and settles at once, both start together; on a fake clock the step ends at 30 s with only the hanging one logged as ran out of time, and the stop goes on", async () => { + const started: string[] = []; + const { clock, daemon, sink } = await start({ + modules: definitions( + fakeModule({ + hooks: { + beforeDrain: () => { + started.push("hangs"); + return new Promise(() => undefined); + }, + }, + name: "hangs", + }), + fakeModule({ + hooks: { + beforeDrain: () => { + started.push("quick"); + return Promise.resolve(); + }, + }, + name: "quick", + }), + ), + on: ["hangs", "quick"], + }); + let stopped = false; + + const stopping = daemon.stop("test").then(() => { + stopped = true; + }); + await vi.waitFor(() => expect([...started].sort()).toEqual(["hangs", "quick"])); + clock.advance(29_999); + await sleep(30); + const stoppedEarly = stopped; + clock.advance(1); + await stopping; + + expect(stoppedEarly).toBe(false); + expect( + recordsOf(sink, "Optional module hook ran out of time").map((record) => record.fields), + ).toEqual([{ hook: "beforeDrain", module: "hangs" }]); + }); + + it("a beforeDrain that throws is logged as failed, and the HTTP gateway still stops", async () => { + const { daemon, origin, sink } = await start({ + http: true, + modules: definitions( + fakeModule({ + hooks: { + beforeDrain: () => Promise.reject(new Error("drain broke")), + }, + name: "alpha", + }), + ), + on: ["alpha"], + }); + expect((await fetch(`${origin}/v1/healthz`)).status).toBe(200); + + await daemon.stop("test"); + const afterStop = await fetch(`${origin}/v1/healthz`).then( + () => "answered", + () => "refused", + ); + + expect(recordsOf(sink, "Optional module hook failed").map((record) => record.fields)).toEqual([ + { hook: "beforeDrain", message: "drain broke", module: "alpha" }, + ]); + expect(afterStop).toBe("refused"); + }); + + it("a beforeDriversDispose that throws is logged, and the drivers are still disposed", async () => { + const notes: string[] = []; + const { daemon, sink } = await start({ + modules: definitions( + fakeModule({ + hooks: { beforeDriversDispose: () => Promise.reject(new Error("dispose broke")) }, + name: "alpha", + }), + ), + notes, + on: ["alpha"], + }); + + await daemon.stop("test"); + + expect(recordsOf(sink, "Optional module hook failed").map((record) => record.fields)).toEqual([ + { hook: "beforeDriversDispose", message: "dispose broke", module: "alpha" }, + ]); + expect(notes).toContain("driver dispose"); + }); + + it("a beforeDriversDispose that runs past 30 s on a fake clock is logged, and the drivers are still disposed", async () => { + const notes: string[] = []; + const { clock, daemon, sink } = await start({ + modules: definitions( + fakeModule({ + hooks: { + beforeDriversDispose: () => { + notes.push("hook started"); + return new Promise(() => undefined); + }, + }, + name: "alpha", + }), + ), + notes, + on: ["alpha"], + }); + + const stopping = daemon.stop("test"); + await vi.waitFor(() => expect(notes).toContain("hook started")); + clock.advance(30_000); + await stopping; + + expect( + recordsOf(sink, "Optional module hook ran out of time").map((record) => record.fields), + ).toEqual([{ hook: "beforeDriversDispose", module: "alpha" }]); + expect(notes).toContain("driver dispose"); + }); +}); diff --git a/src/daemon/optional-modules.test.ts-E b/src/daemon/optional-modules.test.ts-E new file mode 100644 index 000000000..1da02a2e2 --- /dev/null +++ b/src/daemon/optional-modules.test.ts-E @@ -0,0 +1,882 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FakeAgentDevice } from "../agent-device/testing.js"; +import { FakeDriver } from "../core/testing.js"; +import type { OptionalModuleDefinition, WorkerHooks } from "../modules/index.js"; +import { fakeModule, type FakeModule } from "../modules/testing.js"; +import { FakeClock, JsonLinesLogger, MemoryFilesystem, MemoryLogSink } from "../ports/index.js"; +import { startDaemon, type StartDaemonOptions } from "./main.js"; +import type { DaemonServer } from "./server.js"; +import { openRelay, openUplink, relayInput, settleWithin } from "./test-relay-support.js"; + +const admin = { + manageEventSubscription: () => undefined, + principal: "test-operator", + role: "admin", +} as const; +const agent = { + manageEventSubscription: () => undefined, + principal: "agent-1", + role: "agent", +} as const; +const ios = { + model: "iPhone 16", + osVersion: "26.5", + platform: "ios", + requesterId: "agent-1", +} as const; + +const daemons: DaemonServer[] = []; +const directories: string[] = []; +const servers: Server[] = []; + +afterEach(async () => { + await Promise.all(daemons.splice(0).map((daemon) => daemon.stop("test").catch(() => undefined))); + await Promise.all(servers.splice(0).map((server) => new Promise((done) => server.close(done)))); + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +async function freeLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + await new Promise((resolve) => server.close(() => resolve())); + if (typeof address !== "object" || address === null) throw new Error("no port bound"); + return address.port; +} + +/** A driver that notes, in a shared list, when it is disposed or asked to reclaim a device. */ +class NotingDriver extends FakeDriver { + constructor( + options: ConstructorParameters[0], + readonly notes: string[], + ) { + super(options); + } + + override async reclaim(...args: Parameters) { + this.notes.push("driver reclaim"); + return super.reclaim(...args); + } + + async dispose(): Promise { + this.notes.push("driver dispose"); + } +} + +interface Rig { + readonly daemon: DaemonServer; + readonly clock: FakeClock; + readonly sink: MemoryLogSink; + readonly directory: string; + readonly notes: string[]; + readonly origin: string | undefined; + readonly driver: NotingDriver; +} + +interface RigOptions { + /** The modules the daemon is given, in registry order. */ + readonly modules: readonly OptionalModuleDefinition[]; + /** The module names turned on with `capabilities..enabled`. */ + readonly on?: readonly string[]; + readonly http?: boolean; + readonly extra?: Partial; + readonly overrides?: Record; + readonly notes?: string[]; +} + +function definitions(...modules: FakeModule[]): OptionalModuleDefinition[] { + return modules.map((module) => module.definition as OptionalModuleDefinition); +} + +async function prepare(options: RigOptions) { + const directory = await mkdtemp(join(tmpdir(), "simlock-modules-")); + directories.push(directory); + const clock = new FakeClock(1_000); + const sink = new MemoryLogSink(); + const notes = options.notes ?? []; + const driver = new NotingDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }, notes); + const port = options.http === true ? await freeLoopbackPort() : undefined; + const startOptions = { + clock, + configOverrides: { + capabilities: Object.fromEntries((options.on ?? []).map((name) => [name, { enabled: true }])), + ...(port === undefined ? {} : { http: { enabled: true, host: "127.0.0.1", port } }), + ...options.overrides, + }, + dataDirectory: directory, + drivers: [driver], + filesystem: new MemoryFilesystem(), + hostLoadProbe: null, + logger: new JsonLinesLogger({ clock, level: "debug", sink }), + optionalModules: options.modules, + socketPath: join(directory, "daemon.sock"), + statePath: join(directory, "state.json"), + version: "1.2.3", + ...options.extra, + } as StartDaemonOptions; + return { + clock, + directory, + driver, + notes, + origin: port === undefined ? undefined : `http://127.0.0.1:${String(port)}`, + sink, + startOptions, + }; +} + +async function start(options: RigOptions): Promise { + const prepared = await prepare(options); + const daemon = await startDaemon(prepared.startOptions); + daemons.push(daemon); + return { ...prepared, daemon }; +} + +async function agentHeaders(daemon: DaemonServer): Promise> { + const { secret } = await daemon.dispatch("token.create", { label: "a", role: "agent" }, admin); + return { authorization: `Bearer ${secret}` }; +} + +async function capabilities(daemon: DaemonServer) { + const status = (await daemon.dispatch("status.get", {}, agent)) as { + readonly capabilities?: readonly { name: string; state: string; reason?: string }[]; + }; + return status.capabilities ?? []; +} + +function recordsOf(sink: MemoryLogSink, message: string) { + return sink.records.filter((record) => record.message === message); +} + +async function leaseAndRelease(daemon: DaemonServer): Promise { + const grant = (await daemon.dispatch("lease.request", ios, agent)) as { lease: { id: string } }; + await daemon.dispatch("lease.release", { leaseId: grant.lease.id }, agent); + return grant.lease.id; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe("optional modules: turning on", () => { + it("a module with no capabilities section, or with enabled false, is reported off in status.get, and its loadWorker is never called", async () => { + const bare = fakeModule({ name: "alpha" }); + const disabled = fakeModule({ name: "bravo" }); + const { daemon } = await start({ + modules: definitions(bare, disabled), + overrides: { capabilities: { bravo: { enabled: false } } }, + }); + + expect(await capabilities(daemon)).toEqual( + expect.arrayContaining([ + { name: "alpha", state: "off" }, + { name: "bravo", state: "off" }, + ]), + ); + expect({ alpha: bare.loads, bravo: disabled.loads }).toEqual({ alpha: 0, bravo: 0 }); + }); + + it("a module turned on is loaded once and reports the state its capability source answers", async () => { + const module = fakeModule({ name: "alpha", state: "busy" }); + const { daemon } = await start({ modules: definitions(module), on: ["alpha"] }); + + expect(await capabilities(daemon)).toContainEqual({ name: "alpha", state: "busy" }); + expect({ loads: module.loads, creates: module.creates }).toEqual({ creates: 1, loads: 1 }); + }); + + it("the capability list is agent-device's entry first, then registered modules in registry order", async () => { + const { daemon } = await start({ + extra: { agentDeviceUpstream: new FakeAgentDevice() }, + modules: definitions(fakeModule({ name: "zulu" }), fakeModule({ name: "alpha" })), + on: ["zulu", "alpha"], + overrides: { agentDevice: { enabled: true } }, + }); + + expect((await capabilities(daemon)).map((entry) => entry.name)).toEqual([ + "agent-device", + "zulu", + "alpha", + ]); + }); + + it("config.get returns capabilities with each module's section", async () => { + const { daemon } = await start({ + modules: definitions(fakeModule({ name: "alpha" })), + on: ["alpha"], + }); + + const config = (await daemon.dispatch("config.get", {}, admin)) as { + readonly capabilities?: unknown; + }; + + expect(config.capabilities).toEqual({ alpha: { enabled: true, greeting: "hello" } }); + }); +}); + +describe("optional modules: HTTP routes", () => { + const handler = (request: Request, identity: { readonly role: string }) => + Promise.resolve(Response.json({ path: new URL(request.url).pathname, role: identity.role })); + + it("a module turned on serves /v1/ and /v1//* through its handler, behind agent auth", async () => { + const { daemon, origin } = await start({ + http: true, + modules: definitions(fakeModule({ mount: () => handler, name: "alpha" })), + on: ["alpha"], + }); + const headers = await agentHeaders(daemon); + + const root = await fetch(`${origin}/v1/alpha`, { headers }); + const deep = await fetch(`${origin}/v1/alpha/deep/x`, { headers }); + const anonymous = await fetch(`${origin}/v1/alpha`); + + expect(await root.json()).toEqual({ path: "/v1/alpha", role: "agent" }); + expect(await deep.json()).toEqual({ path: "/v1/alpha/deep/x", role: "agent" }); + expect(anonymous.status).toBe(401); + }); + + it("a module named like a core route's first segment (status) fails the start, with HTTP off too", async () => { + const accepted = await start({ modules: definitions(fakeModule({ name: "stats" })) }); + expect(accepted.daemon).toBeDefined(); + + await expect(start({ modules: definitions(fakeModule({ name: "status" })) })).rejects.toThrow( + "status", + ); + }); + + it("with agent-device on and a fake module registered, both modules' routes and relay targets answer, and both capabilities are listed", async () => { + const seen: string[] = []; + const { daemon, origin } = await start({ + extra: { agentDeviceUpstream: new FakeAgentDevice() }, + http: true, + modules: definitions( + fakeModule({ + mount: () => handler, + name: "alpha", + relayTarget: (_request, tenant) => { + seen.push(tenant); + return Promise.resolve(new Response("alpha")); + }, + }), + ), + on: ["alpha"], + overrides: { agentDevice: { enabled: true } }, + }); + const headers = await agentHeaders(daemon); + const uplink = await openUplink(daemon); + + const alphaRoute = await fetch(`${origin}/v1/alpha/ping`, { headers }); + const agentDeviceRoute = await fetch(`${origin}/v1/agent-device/health`, { headers }); + const alphaRelay = await openRelay(uplink, relayInput({ target: "alpha" })); + const agentDeviceRelay = await settleWithin( + (await openRelay(uplink, relayInput({ target: "agent-device" }))).result, + ); + const nobodyRelay = await settleWithin( + (await openRelay(uplink, relayInput({ target: "nobody" }))).result, + ); + + expect([alphaRoute.status, agentDeviceRoute.status]).toEqual([200, 200]); + expect(seen).toEqual(["gw.g1.alice"]); + expect((await alphaRelay.body()).state).toBe("fulfilled"); + expect(nobodyRelay).toMatchObject({ code: "RELAY_TARGET_MISSING", state: "rejected" }); + expect(agentDeviceRelay).not.toMatchObject({ code: "RELAY_TARGET_MISSING" }); + expect((await capabilities(daemon)).map((entry) => entry.name)).toEqual([ + "agent-device", + "alpha", + ]); + }); +}); + +describe("optional modules: relay targets", () => { + it("a relay request to a module's name reaches its relay target with the tenant and lease id", async () => { + const calls: { tenant: string; leaseId: string | undefined }[] = []; + const { daemon } = await start({ + modules: definitions( + fakeModule({ + name: "alpha", + relayTarget: (_request, tenant, leaseId) => { + calls.push({ leaseId, tenant }); + return Promise.resolve(new Response("from alpha")); + }, + }), + ), + on: ["alpha"], + }); + const uplink = await openUplink(daemon); + + const relay = await openRelay( + uplink, + relayInput({ leaseId: "lease-9", target: "alpha", tenant: "gw.g1.bob" }), + ); + const body = await relay.body(); + + expect(calls).toEqual([{ leaseId: "lease-9", tenant: "gw.g1.bob" }]); + expect(body.state === "fulfilled" ? Buffer.from(body.value).toString() : body.state).toBe( + "from alpha", + ); + }); + + it("a test's relayTargets option still wins over a module's relay target", async () => { + const { daemon } = await start({ + extra: { relayTargets: { alpha: () => Promise.resolve(new Response("from the test")) } }, + modules: definitions( + fakeModule({ + name: "alpha", + relayTarget: () => Promise.resolve(new Response("from alpha")), + }), + ), + on: ["alpha"], + }); + const uplink = await openUplink(daemon); + + const body = await (await openRelay(uplink, relayInput({ target: "alpha" }))).body(); + + expect(body.state === "fulfilled" ? Buffer.from(body.value).toString() : body.state).toBe( + "from the test", + ); + }); +}); + +describe("optional modules: doctor findings", () => { + it("a fake module's finding reaches doctor.run as module-unavailable with module, message and remedy; a second module whose findings() throws loses only its own", async () => { + const { daemon, sink } = await start({ + modules: definitions( + fakeModule({ + findings: () => [{ message: "it is down", remedy: "bring it up" }], + name: "alpha", + }), + fakeModule({ + findings: () => { + throw new Error("boom"); + }, + name: "bravo", + }), + ), + on: ["alpha", "bravo"], + }); + + const report = (await daemon.dispatch("doctor.run", {}, admin)) as { + readonly findings: readonly { readonly kind: string }[]; + }; + + expect(report.findings).toContainEqual({ + kind: "module-unavailable", + message: "it is down", + module: "alpha", + remedy: "bring it up", + }); + expect(report.findings.filter((finding) => finding.kind === "module-unavailable")).toHaveLength( + 1, + ); + expect( + recordsOf(sink, "Could not read optional module findings").map((record) => record.fields), + ).toEqual([{ message: "boom", module: "bravo" }]); + }); +}); + +describe("optional modules: a start that cannot go on", () => { + const cases: readonly { + readonly title: string; + readonly good: () => FakeModule[]; + readonly bad: () => FakeModule[]; + readonly names: readonly string[]; + readonly overrides?: Record; + }[] = [ + { + bad: () => [fakeModule({ capabilityName: "beta", name: "alpha" })], + good: () => [fakeModule({ name: "alpha" })], + names: ["alpha", "beta"], + title: "a capability name that differs from the definition's", + }, + { + bad: () => [fakeModule({ name: "alpha" }), fakeModule({ name: "alpha" })], + good: () => [fakeModule({ name: "alpha" }), fakeModule({ name: "bravo" })], + names: ["alpha"], + title: "two definitions with one name", + }, + { + bad: () => [fakeModule({ name: "agent-device" })], + good: () => [fakeModule({ name: "agent-devices" })], + names: ["agent-device"], + title: "a definition named agent-device while it is wired by hand", + }, + { + bad: () => [fakeModule({ name: "Bad_Name" })], + good: () => [fakeModule({ name: "good-name" })], + names: ["Bad_Name"], + title: "a name that fails the capability name check", + }, + { + bad: () => Array.from({ length: 16 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), + good: () => Array.from({ length: 15 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), + names: ["16"], + title: "16 fake modules plus agent-device wired by hand (17 capabilities)", + }, + { + bad: () => [fakeModule({ loadError: new Error("no such file"), name: "alpha" })], + good: () => [fakeModule({ name: "alpha" })], + names: ["alpha", "no such file"], + title: "a loadWorker() that throws", + }, + ]; + + it.each(cases)("the start fails, naming the module, for $title", async ({ bad, good, names }) => { + const turnOn = (modules: FakeModule[]) => modules.map((m) => m.definition.name); + const goodModules = good(); + await start({ modules: definitions(...goodModules), on: turnOn(goodModules) }); + + const failure = await start({ modules: definitions(...bad()), on: turnOn(bad()) }).then( + () => undefined, + (error: unknown) => error as Error, + ); + + expect(failure).toBeInstanceOf(Error); + for (const name of names) expect(failure?.message).toContain(name); + }); + + it("a module that calls ctx.cancelPending in createWorker fails the start, naming the module", async () => { + const quiet = fakeModule({ name: "alpha" }); + await start({ modules: definitions(quiet), on: ["alpha"] }); + + const failure = await start({ + modules: definitions( + fakeModule({ name: "alpha", onCreate: (ctx) => void ctx.cancelPending("agent-1") }), + ), + on: ["alpha"], + }).then( + () => undefined, + (error: unknown) => error as Error, + ); + + expect(failure).toBeInstanceOf(Error); + expect(failure?.message).toContain("alpha"); + expect(failure?.message).toContain("leasing is not ready yet"); + }); + + it("when the second of two modules throws in createWorker, the first module's afterSocketClaimed never runs", async () => { + const claimed = vi.fn(); + const first = fakeModule({ hooks: { afterSocketClaimed: claimed }, name: "alpha" }); + const failure = await start({ + modules: definitions( + first, + fakeModule({ + name: "bravo", + onCreate: () => { + throw new Error("bravo broke"); + }, + }), + ), + on: ["alpha", "bravo"], + }).then( + () => undefined, + (error: unknown) => error as Error, + ); + await sleep(50); + + expect(failure).toBeInstanceOf(Error); + expect(failure?.message).toContain("bravo"); + expect({ claimed: claimed.mock.calls.length, created: first.creates }).toEqual({ + claimed: 0, + created: 1, + }); + }); + + it("when the HTTP gateway fails to bind after the socket is claimed, the daemon stops and each started module's beforeDrain and beforeDriversDispose run", async () => { + const taken = createServer(); + servers.push(taken); + await new Promise((resolve) => taken.listen(0, "127.0.0.1", resolve)); + const { port } = taken.address() as { port: number }; + const notes: string[] = []; + const hooks: WorkerHooks = { + beforeDrain: () => { + notes.push("drain"); + return Promise.resolve(); + }, + beforeDriversDispose: () => { + notes.push("dispose"); + return Promise.resolve(); + }, + }; + + const outcome = await prepare({ + modules: definitions(fakeModule({ hooks, name: "alpha" })), + notes, + on: ["alpha"], + overrides: { http: { enabled: true, host: "127.0.0.1", port } }, + }).then((prepared) => + startDaemon(prepared.startOptions).then( + () => "started", + () => "rejected", + ), + ); + await vi.waitFor(() => expect(notes).toContain("dispose")); + + expect(outcome).toBe("rejected"); + expect(notes.filter((note) => note === "drain" || note === "dispose")).toEqual([ + "drain", + "dispose", + ]); + }); +}); + +describe("optional modules: the context a module receives", () => { + it("has a logger named after the module, and events.subscribe delivers a bus event", async () => { + const delivered: string[] = []; + const { daemon, sink } = await start({ + modules: definitions( + fakeModule({ + name: "alpha", + onCreate: (ctx) => { + ctx.logger.info("alpha says hello"); + ctx.events.subscribe("lease.released", (envelope) => { + delivered.push(envelope.payload.leaseId); + }); + }, + }), + ), + on: ["alpha"], + }); + + const leaseId = await leaseAndRelease(daemon); + + expect(recordsOf(sink, "alpha says hello").map((record) => record.module)).toEqual([ + "daemon.alpha", + ]); + expect(delivered).toEqual([leaseId]); + }); +}); + +describe("optional modules: afterSocketClaimed", () => { + it("runs once after the socket is claimed, and the start does not wait for it", async () => { + let socketPath = ""; + const seen: boolean[] = []; + const never = new Promise(() => undefined); + const prepared = await prepare({ + modules: definitions( + fakeModule({ + hooks: { + afterSocketClaimed: () => { + seen.push(existsSync(socketPath)); + return never; + }, + }, + name: "alpha", + }), + ), + on: ["alpha"], + }); + socketPath = join(prepared.directory, "daemon.sock"); + + const started = await settleWithin(startDaemon(prepared.startOptions), 5_000); + if (started.state === "fulfilled") daemons.push(started.value); + await sleep(50); + + expect(started.state).toBe("fulfilled"); + expect(seen).toEqual([true]); + }); + + it("never runs in a daemon that lost the socket race", async () => { + const claimed = vi.fn(); + const filesystem = new MemoryFilesystem(); + const directory = await mkdtemp(join(tmpdir(), "simlock-modules-race-")); + directories.push(directory); + const options = (modules: readonly OptionalModuleDefinition[]) => + ({ + clock: new FakeClock(1_000), + configOverrides: { capabilities: { alpha: { enabled: true } } }, + dataDirectory: directory, + drivers: [ + new FakeDriver({ + availableOsVersions: ["26.5"], + clock: new FakeClock(1_000), + platform: "ios", + }), + ], + filesystem, + logger: new JsonLinesLogger({ + clock: new FakeClock(1_000), + level: "debug", + sink: new MemoryLogSink(), + }), + optionalModules: modules, + statePath: join(directory, "state.json"), + version: "1.2.3", + }) as StartDaemonOptions; + const first = await startDaemon(options([])); + daemons.push(first); + + const loser = fakeModule({ hooks: { afterSocketClaimed: claimed }, name: "alpha" }); + const second = await startDaemon(options(definitions(loser))).then( + () => "started", + () => "rejected", + ); + await sleep(50); + + expect({ claimed: claimed.mock.calls.length, created: loser.creates, second }).toEqual({ + claimed: 0, + created: 1, + second: "rejected", + }); + }); + + it.each([ + [ + "throws", + () => { + throw new Error("hook broke"); + }, + ], + ["rejects", () => Promise.reject(new Error("hook broke"))], + ] as const)( + "an afterSocketClaimed that %s is logged as failed, and the daemon still starts", + async (_how, hook) => { + const { sink } = await start({ + modules: definitions(fakeModule({ hooks: { afterSocketClaimed: hook }, name: "alpha" })), + on: ["alpha"], + }); + + await vi.waitFor(() => + expect( + recordsOf(sink, "Optional module hook failed").map((record) => record.fields), + ).toEqual([{ hook: "afterSocketClaimed", message: "hook broke", module: "alpha" }]), + ); + }, + ); +}); + +describe("optional modules: beforeDeviceReclaim", () => { + it("a release reclaims the device when no turned-on module has beforeDeviceReclaim", async () => { + const notes: string[] = []; + const { daemon } = await start({ + modules: definitions(fakeModule({ name: "alpha" })), + notes, + on: ["alpha"], + }); + + await leaseAndRelease(daemon); + + await vi.waitFor(() => expect(notes).toContain("driver reclaim")); + }); + + it("beforeDeviceReclaim runs after a release commits and before the device's reclaim starts", async () => { + const notes: string[] = []; + let live: DaemonServer | undefined; + const { daemon } = await start({ + modules: definitions( + fakeModule({ + hooks: { + beforeDeviceReclaim: async (lease) => { + const listed = (await live?.dispatch("lease.list", {}, admin)) as { + leases: { id: string }[]; + }; + notes.push( + listed.leases.some((entry) => entry.id === lease.leaseId) + ? "lease still listed" + : "lease gone", + ); + await sleep(30); + notes.push("hook done"); + }, + }, + name: "alpha", + }), + ), + notes, + on: ["alpha"], + }); + live = daemon; + + await leaseAndRelease(daemon); + await vi.waitFor(() => expect(notes).toContain("driver reclaim")); + + expect(notes).toEqual(["lease gone", "hook done", "driver reclaim"]); + }); +}); + +describe("optional modules: stop hooks", () => { + it("beforeDrain finishes before the HTTP gateway stops and before leasing's settle starts", async () => { + let origin: string | undefined; + let moduleLog: { info(message: string): void } | undefined; + const notes: string[] = []; + const rig = await start({ + http: true, + modules: definitions( + fakeModule({ + hooks: { + beforeDrain: async () => { + await sleep(50); + const health = await fetch(`${origin}/v1/healthz`).then( + (response) => String(response.status), + () => "refused", + ); + notes.push(`gateway ${health}`); + moduleLog?.info("drain done"); + }, + }, + name: "alpha", + onCreate: (ctx) => { + moduleLog = ctx.logger; + }, + }), + ), + on: ["alpha"], + }); + origin = rig.origin; + + await rig.daemon.stop("test"); + + const messages = rig.sink.records.map((record) => record.message); + expect(notes).toEqual(["gateway 200"]); + expect(messages.indexOf("drain done")).toBeGreaterThanOrEqual(0); + expect(messages.indexOf("drain done")).toBeLessThan(messages.indexOf("Daemon stopping")); + }); + + it("beforeDriversDispose finishes before any driver's dispose starts", async () => { + const notes: string[] = []; + const { daemon } = await start({ + modules: definitions( + fakeModule({ + hooks: { + beforeDriversDispose: async () => { + await sleep(30); + notes.push("hook done"); + }, + }, + name: "alpha", + }), + ), + notes, + on: ["alpha"], + }); + + await daemon.stop("test"); + + expect(notes).toEqual(["hook done", "driver dispose"]); + }); + + it("with two modules whose beforeDrain hangs and settles at once, both start together; on a fake clock the step ends at 30 s with only the hanging one logged as ran out of time, and the stop goes on", async () => { + const started: string[] = []; + const { clock, daemon, sink } = await start({ + modules: definitions( + fakeModule({ + hooks: { + beforeDrain: () => { + started.push("hangs"); + return new Promise(() => undefined); + }, + }, + name: "hangs", + }), + fakeModule({ + hooks: { + beforeDrain: () => { + started.push("quick"); + return Promise.resolve(); + }, + }, + name: "quick", + }), + ), + on: ["hangs", "quick"], + }); + let stopped = false; + + const stopping = daemon.stop("test").then(() => { + stopped = true; + }); + await vi.waitFor(() => expect([...started].sort()).toEqual(["hangs", "quick"])); + clock.advance(29_999); + await sleep(30); + const stoppedEarly = stopped; + clock.advance(1); + await stopping; + + expect(stoppedEarly).toBe(false); + expect( + recordsOf(sink, "Optional module hook ran out of time").map((record) => record.fields), + ).toEqual([{ hook: "beforeDrain", module: "hangs" }]); + }); + + it("a beforeDrain that throws is logged as failed, and the HTTP gateway still stops", async () => { + const { daemon, origin, sink } = await start({ + http: true, + modules: definitions( + fakeModule({ + hooks: { + beforeDrain: () => Promise.reject(new Error("drain broke")), + }, + name: "alpha", + }), + ), + on: ["alpha"], + }); + expect((await fetch(`${origin}/v1/healthz`)).status).toBe(200); + + await daemon.stop("test"); + const afterStop = await fetch(`${origin}/v1/healthz`).then( + () => "answered", + () => "refused", + ); + + expect(recordsOf(sink, "Optional module hook failed").map((record) => record.fields)).toEqual([ + { hook: "beforeDrain", message: "drain broke", module: "alpha" }, + ]); + expect(afterStop).toBe("refused"); + }); + + it("a beforeDriversDispose that throws is logged, and the drivers are still disposed", async () => { + const notes: string[] = []; + const { daemon, sink } = await start({ + modules: definitions( + fakeModule({ + hooks: { beforeDriversDispose: () => Promise.reject(new Error("dispose broke")) }, + name: "alpha", + }), + ), + notes, + on: ["alpha"], + }); + + await daemon.stop("test"); + + expect(recordsOf(sink, "Optional module hook failed").map((record) => record.fields)).toEqual([ + { hook: "beforeDriversDispose", message: "dispose broke", module: "alpha" }, + ]); + expect(notes).toContain("driver dispose"); + }); + + it("a beforeDriversDispose that runs past 30 s on a fake clock is logged, and the drivers are still disposed", async () => { + const notes: string[] = []; + const { clock, daemon, sink } = await start({ + modules: definitions( + fakeModule({ + hooks: { + beforeDriversDispose: () => { + notes.push("hook started"); + return new Promise(() => undefined); + }, + }, + name: "alpha", + }), + ), + notes, + on: ["alpha"], + }); + + const stopping = daemon.stop("test"); + await vi.waitFor(() => expect(notes).toContain("hook started")); + clock.advance(30_000); + await stopping; + + expect( + recordsOf(sink, "Optional module hook ran out of time").map((record) => record.fields), + ).toEqual([{ hook: "beforeDriversDispose", module: "alpha" }]); + expect(notes).toContain("driver dispose"); + }); +}); diff --git a/src/http/core-routes.test.ts b/src/http/core-routes.test.ts new file mode 100644 index 000000000..c4adb8c29 --- /dev/null +++ b/src/http/core-routes.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { EventBus } from "../bus/index.js"; +import { CORE_ROUTE_SEGMENTS } from "../contract/index.js"; +import { OwnerRoutedFactBus } from "../daemon/owner-routed-facts.js"; +import { FakeClock, JsonLinesLogger, MemoryLogSink } from "../ports/index.js"; +import { createHttpApp } from "./app.js"; +import { + FakeDispatcher, + FakeRegistry, + FakeTokenVerifier, + sequenceIdGenerator, + testConfig, +} from "./test-fakes.js"; + +describe("the routes createHttpApp registers", () => { + it("every route createHttpApp registers starts with a name in CORE_ROUTE_SEGMENTS", () => { + const clock = new FakeClock(1_000); + const eventBus = new EventBus(clock); + const dispatcher = new FakeDispatcher(clock); + const registry = new FakeRegistry(); + const app = createHttpApp({ + clock, + config: testConfig(), + dispatch: (op, input, session) => dispatcher.dispatch(op, input, session) as never, + eventBus, + idGenerator: sequenceIdGenerator("gw"), + leaseRequests: dispatcher.requests, + logger: new JsonLinesLogger({ clock, sink: new MemoryLogSink() }), + ownerRoutedFacts: new OwnerRoutedFactBus(eventBus, registry), + registry, + tokens: new FakeTokenVerifier(), + }); + + const segments = app.routes + .map((route) => route.path) + .filter((path) => path.startsWith("/v1/")) + .map((path) => path.split("/")[2] ?? ""); + + expect(segments.length).toBeGreaterThan(0); + expect(segments.filter((segment) => !CORE_ROUTE_SEGMENTS.includes(segment))).toEqual([]); + }); + + it("CORE_ROUTE_SEGMENTS names the ten core first segments and uplink", () => { + expect([...CORE_ROUTE_SEGMENTS].sort()).toEqual( + [ + "catalog", + "components", + "devices", + "events", + "healthz", + "lease-requests", + "leases", + "status", + "tokens", + "uplink", + "workers", + ].sort(), + ); + }); +}); diff --git a/src/lint-rules.test.ts b/src/lint-rules.test.ts index f0defd09d..a103fbb81 100644 --- a/src/lint-rules.test.ts +++ b/src/lint-rules.test.ts @@ -439,6 +439,41 @@ const pathCases: readonly (readonly [string, readonly Expectation[]])[] = [ ["fails", "src/daemon/main.ts", "../host-load/sampler.js"], ], ], + [ + "fails a modules file importing anything outside ports, contract and core, and passes those three", + [ + ["fails", "src/modules/x.ts", "../leasing/index.js"], + ["fails", "src/modules/x.ts", "../gateway/index.js"], + ["fails", "src/modules/x.ts", "../agent-device/index.js"], + ["fails", "src/modules/x.ts", "../bus/index.js"], + ["passes", "src/modules/x.ts", "../ports/index.js"], + ["passes", "src/modules/x.ts", "../contract/index.js"], + ["passes", "src/modules/x.ts", "../core/index.js"], + ], + ], + [ + "fails a core, leasing, driver, gateway or contract file importing modules or module-registry", + [ + ["fails", "src/core/x.ts", "../modules/index.js"], + ["fails", "src/core/x.ts", "../module-registry/index.js"], + ["fails", "src/leasing/x.ts", "../modules/index.js"], + ["fails", "src/leasing/x.ts", "../module-registry/index.js"], + ["fails", "src/drivers/ios/x.ts", "../../modules/index.js"], + ["fails", "src/drivers/android/x.ts", "../../module-registry/index.js"], + ["fails", "src/gateway/x.ts", "../modules/index.js"], + ["fails", "src/gateway/x.ts", "../module-registry/index.js"], + ["fails", "src/contract/x.ts", "../modules/index.js"], + ["fails", "src/contract/x.ts", "../module-registry/index.js"], + ], + ], + [ + "passes the composition root importing modules' and module-registry's index", + [ + ["passes", "src/daemon/main.ts", "../modules/index.js"], + ["passes", "src/daemon/main.ts", "../module-registry/index.js"], + ["fails", "src/daemon/main.ts", "../modules/host.js"], + ], + ], [ "passes an agent-device test file importing node:fs", [["passes", "src/agent-device/host/x.test.ts", "node:fs"]], @@ -499,6 +534,46 @@ describe("import-path rules enforced by dependency-cruiser", () => { }); }); +describe("a module's definition.ts, read by the registry without loading the module", () => { + const world: Record = { + "src/heavy/definition.ts": 'import { heavy } from "./impl.js";\nexport const d = heavy;\n', + "src/heavy/impl.ts": "export const heavy = 1;\n", + "src/heavy/index.ts": "export {};\n", + "src/light/definition.ts": + 'import type { Light } from "./impl.js";\nexport type D = Light;\nexport const load = () => import("./impl.js");\n', + "src/light/impl.ts": "export type Light = number;\n", + "src/light/index.ts": "export {};\n", + "src/module-registry/index.ts": + 'import "../heavy/definition.js";\nimport "../light/definition.js";\nexport {};\n', + }; + + function worldViolations(): Violation[] { + const dir = join(scratch, `definitions-${counter++}`); + for (const [path, source] of Object.entries(world)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), source); + } + copyFileSync(join(repoRoot, ".dependency-cruiser.mjs"), join(dir, ".dependency-cruiser.mjs")); + copyFileSync(join(repoRoot, "tsconfig.json"), join(dir, "tsconfig.json")); + return cruise(dir, ["src"]); + } + + it("dependency-cruiser fails a definition.ts that imports a value from its own module, and passes a type-only and a dynamic-import one", () => { + const found = worldViolations(); + + expect( + found.filter((v) => v.from === "src/heavy/definition.ts").map((v) => v.rule.name), + ).toEqual(["definition-is-light"]); + expect(found.filter((v) => v.from === "src/light/definition.ts")).toEqual([]); + }); + + it("dependency-cruiser lets the registry import a module's definition.ts, and no other file past its index", () => { + const found = worldViolations(); + + expect(found.filter((v) => v.from === "src/module-registry/index.ts")).toEqual([]); + }); +}); + describe("the cycle check between components", () => { function cycleWorld(files: Record): Violation[] { const dir = join(scratch, `cycles-${counter++}`); diff --git a/src/module-registry/index.ts b/src/module-registry/index.ts new file mode 100644 index 000000000..66dbbee90 --- /dev/null +++ b/src/module-registry/index.ts @@ -0,0 +1,3 @@ +import type { OptionalModuleDefinition } from "../modules/index.js"; + +export const optionalModules: readonly OptionalModuleDefinition[] = []; diff --git a/src/modules/conformance.test.ts b/src/modules/conformance.test.ts new file mode 100644 index 000000000..25276118d --- /dev/null +++ b/src/modules/conformance.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; + +import { loadConfig } from "../core/index.js"; +import { optionalModules } from "../module-registry/index.js"; +import { FakeSystemStats, MemoryFilesystem, NoopLogger } from "../ports/index.js"; +import { createModuleHost, type ModuleEndedLease, type OptionalModuleDefinition } from "./index.js"; +import { fakeModule, fakeWorkerContext } from "./testing.js"; + +const HOOK_DEADLINE_MS = 30_000; + +const ended: ModuleEndedLease = { + deviceId: "dev-1", + leaseId: "lease-1", + ownerId: "agent-1", + platform: "ios", + reason: "explicit", +}; + +const hooked = fakeModule({ + hooks: { + afterSocketClaimed: () => undefined, + beforeDeviceReclaim: (_lease, signal) => + new Promise((resolve) => { + if (signal.aborted) resolve(); + signal.addEventListener("abort", () => resolve(), { once: true }); + }), + beforeDrain: () => Promise.resolve(), + beforeDriversDispose: () => Promise.resolve(), + }, + name: "hooked", +}); +const plain = fakeModule({ name: "plain" }); + +const subjects: readonly (readonly [string, OptionalModuleDefinition])[] = [ + ...optionalModules.map((definition) => [definition.name, definition] as const), + ...[hooked, plain].map( + (module) => + [module.definition.name, module.definition as OptionalModuleDefinition] as const, + ), +]; + +async function configFor(definition: OptionalModuleDefinition, on: boolean) { + return loadConfig({ + filesystem: new MemoryFilesystem(), + modules: [{ ...definition.config, name: definition.name }], + overrides: { capabilities: { [definition.name]: { enabled: on } } } as never, + systemStats: new FakeSystemStats({ cpuCount: 4, totalRamBytes: 16 * 1024 ** 3 }), + }); +} + +describe("every optional module's contract", () => { + it.each(subjects)( + "%s: the name passes the capability name check, and no name repeats", + (name) => { + expect(name).toMatch(/^[a-z][a-z0-9-]{0,31}$/); + expect(subjects.filter(([other]) => other === name)).toHaveLength(1); + }, + ); + + it.each(subjects)("%s: off means not loaded, and reported off", async (name, definition) => { + const loadWorker = vi.spyOn(definition, "loadWorker"); + const { ctx } = await fakeWorkerContext(name, definition.config.defaults); + + const host = await createModuleHost({ + config: await configFor(definition, false), + context: ctx, + definitions: [definition], + logger: new NoopLogger(), + role: "worker", + wiredElsewhere: [], + }); + + expect(loadWorker).not.toHaveBeenCalled(); + expect(await host.capabilitySources[0]?.read()).toEqual({ state: "off" }); + }); + + it.each(subjects)("%s: createWorker starts nothing", async (name, definition) => { + const { ctx, clock, processRunner, processSupervisor } = await fakeWorkerContext( + name, + definition.config.defaults, + ); + + const loaded = await definition.loadWorker(); + loaded.createWorker(ctx); + + expect({ + runs: processRunner.calls.length, + signals: processSupervisor.signals.length, + timers: clock.pendingTimerCount, + }).toEqual({ runs: 0, signals: 0, timers: 0 }); + }); + + it.each(subjects)( + "%s: each declared hook settles within its row's deadline", + async (name, definition) => { + const { ctx, clock } = await fakeWorkerContext(name, definition.config.defaults); + const worker = (await definition.loadWorker()).createWorker(ctx); + const hooks = worker.hooks ?? {}; + const controller = new AbortController(); + const settled: string[] = []; + const run = (hook: string, call: (() => unknown) | undefined) => + call === undefined + ? undefined + : Promise.resolve(call()).then(() => { + settled.push(hook); + }); + + const pending = [ + run("afterSocketClaimed", hooks.afterSocketClaimed), + run("beforeDrain", hooks.beforeDrain), + run("beforeDriversDispose", hooks.beforeDriversDispose), + run( + "beforeDeviceReclaim", + hooks.beforeDeviceReclaim === undefined + ? undefined + : () => hooks.beforeDeviceReclaim?.(ended, controller.signal), + ), + ]; + clock.advance(HOOK_DEADLINE_MS); + controller.abort(); + await Promise.all(pending); + + expect(settled.sort()).toEqual(Object.keys(hooks).sort()); + }, + ); +}); diff --git a/src/modules/index.ts b/src/modules/index.ts new file mode 100644 index 000000000..197830a5c --- /dev/null +++ b/src/modules/index.ts @@ -0,0 +1,149 @@ +import type { z } from "zod"; + +import type { EventHandler, EventName } from "../bus/index.js"; +import type { OPERATIONS, OperationName, Role } from "../contract/index.js"; +import type { Config, Platform } from "../core/index.js"; +import type { + Clock, + Filesystem, + Logger, + ProcessRunner, + ProcessSupervisor, + TcpProbe, +} from "../ports/index.js"; + +export interface ModuleConfig { + readonly validator: (value: unknown, path: string, warn: (message: string) => void) => unknown; + readonly defaults: C; + readonly gatewayKeys: readonly string[]; + check?(section: C, config: Config): void; +} + +export interface ModuleFinding { + readonly message: string; + readonly remedy: string; +} + +export interface ModuleEndedLease { + readonly leaseId: string; + readonly ownerId: string; + readonly deviceId: string; + readonly platform: Platform; + readonly reason: "explicit" | "killed" | "expired" | "device-lost"; +} + +export interface ModuleLeaseEndParticipant { + leaseEnded(ended: ModuleEndedLease, signal: AbortSignal): Promise; +} + +export interface ModuleCapabilitySource { + readonly name: string; + readonly read: () => Promise<{ readonly state: string; readonly reason?: string }>; +} + +export type ModuleRelayTarget = ( + request: Request, + tenant: string, + leaseId: string | undefined, +) => Promise; + +export interface ModuleDispatchSession { + readonly principal: string; + readonly role: Role; + readonly manageEventSubscription: (subscribe: boolean) => string | undefined; +} + +export type ModuleDispatch = ( + operation: Op, + input: unknown, + session: ModuleDispatchSession, +) => Promise>; + +export interface ModuleIdentity { + readonly requesterId: string; + readonly role: "agent" | "operator" | "worker"; +} + +export type ModuleHandler = (request: Request, identity: ModuleIdentity) => Promise; + +export interface ModuleMount { + readonly prefix: string; + handle(request: Request, identity: ModuleIdentity): Promise; +} + +export interface MountDeps { + readonly dispatch: ModuleDispatch; +} + +export interface WorkerHooks { + readonly afterSocketClaimed?: () => void | Promise; + readonly beforeDeviceReclaim?: (lease: ModuleEndedLease, signal: AbortSignal) => Promise; + readonly beforeDrain?: () => Promise; + readonly beforeDriversDispose?: () => Promise; +} + +export interface WorkerModule { + readonly capability: ModuleCapabilitySource; + readonly relayTarget?: ModuleRelayTarget; + readonly mount?: (deps: MountDeps) => ModuleHandler; + readonly findings?: () => readonly ModuleFinding[]; + readonly hooks?: WorkerHooks; +} + +export interface WorkerContext { + readonly name: string; + readonly config: C; + readonly daemonConfig: Config; + readonly logger: Logger; + readonly clock: Clock; + readonly filesystem: Filesystem; + readonly processRunner: ProcessRunner; + readonly processSupervisor: ProcessSupervisor; + readonly tcpProbe: TcpProbe; + readonly dataDirectory: string; + readonly environment: NodeJS.ProcessEnv; + readonly nodePath: string; + readonly nodeVersion: string; + readonly platforms: readonly Platform[]; + readonly leaseEnvironment: Readonly>; + cancelPending(requesterId: string): Promise; + readonly events: { + subscribe(event: Event, handler: EventHandler): () => void; + }; +} + +export interface OptionalModuleDefinition { + readonly name: string; + readonly config: ModuleConfig; + loadWorker(): Promise<{ createWorker(ctx: WorkerContext): WorkerModule }>; + loadGateway?(): Promise; +} + +export interface ModuleHost { + readonly capabilitySources: readonly ModuleCapabilitySource[]; + readonly relayTargets: Readonly>; + readonly leaseEndParticipants: readonly ModuleLeaseEndParticipant[]; + mounts(deps: MountDeps): readonly ModuleMount[]; + findings(): readonly { + readonly kind: "module-unavailable"; + readonly module: string; + readonly message: string; + readonly remedy: string; + }[]; + afterSocketClaimed(): void; + beforeDrain(): Promise; + beforeDriversDispose(): Promise; +} + +export interface ModuleHostOptions { + readonly role: "worker"; + readonly definitions: readonly OptionalModuleDefinition[]; + readonly config: Config; + readonly logger: Logger; + readonly context: Omit, "name" | "config" | "daemonConfig" | "logger">; + readonly wiredElsewhere: readonly string[]; +} + +export function createModuleHost(_options: ModuleHostOptions): Promise { + return Promise.reject(new Error("createModuleHost is not built yet")); +} diff --git a/src/modules/testing.ts b/src/modules/testing.ts new file mode 100644 index 000000000..c413a8d3c --- /dev/null +++ b/src/modules/testing.ts @@ -0,0 +1,127 @@ +import { EventBus } from "../bus/index.js"; +import { loadConfig, type Config } from "../core/index.js"; +import { + FakeClock, + FakeProcessSupervisor, + FakeSystemStats, + FakeTcpProbe, + MemoryFilesystem, + NoopLogger, + ScriptedProcessRunner, +} from "../ports/index.js"; +import type { + ModuleFinding, + OptionalModuleDefinition, + WorkerContext, + WorkerHooks, + WorkerModule, +} from "./index.js"; + +export interface FakeModuleConfig { + readonly greeting: string; +} + +export interface FakeModuleOptions { + readonly name: string; + readonly capabilityName?: string; + readonly state?: string; + readonly hooks?: WorkerHooks; + readonly findings?: () => readonly ModuleFinding[]; + readonly relayTarget?: WorkerModule["relayTarget"]; + readonly mount?: WorkerModule["mount"]; + readonly loadError?: Error; + readonly onCreate?: (ctx: WorkerContext) => void; +} + +export interface FakeModule { + readonly definition: OptionalModuleDefinition; + loads: number; + creates: number; + readonly contexts: WorkerContext[]; +} + +export function fakeModule(options: FakeModuleOptions): FakeModule { + const fake: FakeModule = { + contexts: [], + creates: 0, + definition: { + config: { + defaults: { greeting: "hello" }, + gatewayKeys: [], + validator: (value, path, warn) => { + const section = value as Record; + for (const key of Object.keys(section)) { + if (key !== "greeting") warn(`Unknown config key: "${path}.${key}"`); + } + return { greeting: section["greeting"] ?? "hello" }; + }, + }, + loadWorker: () => { + fake.loads += 1; + if (options.loadError !== undefined) return Promise.reject(options.loadError); + return Promise.resolve({ + createWorker: (ctx: WorkerContext): WorkerModule => { + fake.creates += 1; + fake.contexts.push(ctx); + options.onCreate?.(ctx); + return { + capability: { + name: options.capabilityName ?? options.name, + read: () => Promise.resolve({ state: options.state ?? "ready" }), + }, + ...(options.findings === undefined ? {} : { findings: options.findings }), + ...(options.hooks === undefined ? {} : { hooks: options.hooks }), + ...(options.mount === undefined ? {} : { mount: options.mount }), + ...(options.relayTarget === undefined ? {} : { relayTarget: options.relayTarget }), + }; + }, + }); + }, + name: options.name, + }, + loads: 0, + }; + return fake; +} + +export interface FakeWorkerContext { + readonly ctx: WorkerContext; + readonly clock: FakeClock; + readonly processRunner: ScriptedProcessRunner; + readonly processSupervisor: FakeProcessSupervisor; +} + +export async function fakeWorkerContext(name: string, config: C): Promise> { + const clock = new FakeClock(1_000); + const processRunner = new ScriptedProcessRunner([]); + const processSupervisor = new FakeProcessSupervisor(); + const daemonConfig: Config = await loadConfig({ + filesystem: new MemoryFilesystem(), + systemStats: new FakeSystemStats({ cpuCount: 4, totalRamBytes: 16 * 1024 ** 3 }), + }); + const bus = new EventBus(clock); + return { + clock, + ctx: { + cancelPending: () => Promise.resolve(undefined), + clock, + config, + daemonConfig, + dataDirectory: "/tmp/simlock-fake", + environment: {}, + events: { subscribe: (event, handler) => bus.subscribe(event, handler) }, + filesystem: new MemoryFilesystem(), + leaseEnvironment: {}, + logger: new NoopLogger(), + name, + nodePath: "/usr/bin/node", + nodeVersion: "22.0.0", + platforms: ["ios"], + processRunner, + processSupervisor, + tcpProbe: new FakeTcpProbe(), + }, + processRunner, + processSupervisor, + }; +} diff --git a/src/modules/testing.ts-E b/src/modules/testing.ts-E new file mode 100644 index 000000000..c9d51739e --- /dev/null +++ b/src/modules/testing.ts-E @@ -0,0 +1,131 @@ +import { EventBus } from "../bus/index.js"; +import { loadConfig, type Config } from "../core/index.js"; +import { + FakeClock, + FakeProcessSupervisor, + FakeSystemStats, + FakeTcpProbe, + MemoryFilesystem, + NoopLogger, + ScriptedProcessRunner, +} from "../ports/index.js"; +import type { + ModuleFinding, + OptionalModuleDefinition, + WorkerContext, + WorkerHooks, + WorkerModule, +} from "./index.js"; + +export interface FakeModuleConfig { + readonly greeting: string; +} + +export interface FakeModuleOptions { + readonly name: string; + /** The capability name the created handle carries, when it should differ from `name`. */ + readonly capabilityName?: string; + readonly state?: string; + readonly hooks?: WorkerHooks; + readonly findings?: () => readonly ModuleFinding[]; + readonly relayTarget?: WorkerModule["relayTarget"]; + readonly mount?: WorkerModule["mount"]; + readonly loadError?: Error; + /** Runs inside `createWorker`, before the handle is returned. */ + readonly onCreate?: (ctx: WorkerContext) => void; +} + +export interface FakeModule { + readonly definition: OptionalModuleDefinition; + /** How many times the host asked to load it. */ + loads: number; + creates: number; + readonly contexts: WorkerContext[]; +} + +export function fakeModule(options: FakeModuleOptions): FakeModule { + const fake: FakeModule = { + contexts: [], + creates: 0, + definition: { + config: { + defaults: { greeting: "hello" }, + gatewayKeys: [], + validator: (value, path, warn) => { + const section = value as Record; + for (const key of Object.keys(section)) { + if (key !== "greeting") warn(`Unknown config key: "${path}.${key}"`); + } + return { greeting: section["greeting"] ?? "hello" }; + }, + }, + loadWorker: () => { + fake.loads += 1; + if (options.loadError !== undefined) return Promise.reject(options.loadError); + return Promise.resolve({ + createWorker: (ctx: WorkerContext): WorkerModule => { + fake.creates += 1; + fake.contexts.push(ctx); + options.onCreate?.(ctx); + return { + capability: { + name: options.capabilityName ?? options.name, + read: () => Promise.resolve({ state: options.state ?? "ready" }), + }, + ...(options.findings === undefined ? {} : { findings: options.findings }), + ...(options.hooks === undefined ? {} : { hooks: options.hooks }), + ...(options.mount === undefined ? {} : { mount: options.mount }), + ...(options.relayTarget === undefined ? {} : { relayTarget: options.relayTarget }), + }; + }, + }); + }, + name: options.name, + }, + loads: 0, + }; + return fake; +} + +export interface FakeWorkerContext { + readonly ctx: WorkerContext; + readonly clock: FakeClock; + readonly processRunner: ScriptedProcessRunner; + readonly processSupervisor: FakeProcessSupervisor; +} + +/** A context whose every port is a fake that records, so a test can see what a module started. */ +export async function fakeWorkerContext(name: string, config: C): Promise> { + const clock = new FakeClock(1_000); + const processRunner = new ScriptedProcessRunner([]); + const processSupervisor = new FakeProcessSupervisor(); + const daemonConfig: Config = await loadConfig({ + filesystem: new MemoryFilesystem(), + systemStats: new FakeSystemStats({ cpuCount: 4, totalRamBytes: 16 * 1024 ** 3 }), + }); + const bus = new EventBus(clock); + return { + clock, + ctx: { + cancelPending: () => Promise.resolve(undefined), + clock, + config, + daemonConfig, + dataDirectory: "/tmp/simlock-fake", + environment: {}, + events: { subscribe: (event, handler) => bus.subscribe(event, handler) }, + filesystem: new MemoryFilesystem(), + leaseEnvironment: {}, + logger: new NoopLogger(), + name, + nodePath: "/usr/bin/node", + nodeVersion: "22.0.0", + platforms: ["ios"], + processRunner, + processSupervisor, + tcpProbe: new FakeTcpProbe(), + }, + processRunner, + processSupervisor, + }; +} From 4726c9b41d3c763b2d5fefa76e940a748a2e8de6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 13:46:36 +0200 Subject: [PATCH 02/13] feat: optional modules load through one host and are turned on under capabilities (#595) why: the host, the capabilities config namespace, core route names, the dependency rules and protocol 38 turn the spec tests green; Config fixtures gain capabilities: {}. Failing: most -> 1 failing (the 'busy' capability state in optional-modules.test.ts is not a state status.get accepts) --- .dependency-cruiser.mjs | 34 +- src/cli/index.test.ts | 1 + src/cli/index.ts | 6 +- src/contract/AGENTS.md | 2 +- src/contract/protocol.ts | 16 +- src/contract/schemas.ts | 9 +- src/core/cleanup/idle-destroy.test.ts | 1 + src/core/cleanup/idle-shutdown.test.ts | 1 + src/core/config.ts | 81 +- src/core/doctor.test.ts | 1 + src/core/nuke.test.ts | 1 + src/core/reaper.test.ts | 1 + src/daemon-protocol/index.test.ts | 2 +- src/daemon/AGENTS.md | 2 +- src/daemon/dispatcher.test.ts | 1 + src/daemon/main.ts | 70 +- src/daemon/optional-modules.test.ts-E | 882 ------------------ src/daemon/server.test.ts | 6 +- src/gateway/dispatcher.test.ts | 1 + src/gateway/service.test.ts | 2 +- src/http/test-fakes.ts | 1 + src/leasing/acquisition-planner.test.ts | 1 + src/leasing/create-leasing.test.ts | 1 + .../lease-acquisition-coordinator.test.ts | 1 + src/leasing/lease-end-participants.test.ts | 1 + src/leasing/lease-health-monitor.test.ts | 1 + src/leasing/physical-device-watcher.test.ts | 1 + src/leasing/physical-leasing.test.ts | 1 + src/modules/host.ts | 200 ++++ src/modules/index.ts | 170 +--- src/modules/testing.ts | 2 +- src/modules/testing.ts-E | 131 --- src/modules/types.ts | 145 +++ 33 files changed, 587 insertions(+), 1189 deletions(-) delete mode 100644 src/daemon/optional-modules.test.ts-E create mode 100644 src/modules/host.ts delete mode 100644 src/modules/testing.ts-E create mode 100644 src/modules/types.ts diff --git a/.dependency-cruiser.mjs b/.dependency-cruiser.mjs index 68ddc89ec..67c999eac 100644 --- a/.dependency-cruiser.mjs +++ b/.dependency-cruiser.mjs @@ -28,7 +28,7 @@ const pastIndex = components.map((component) => ({ from: { pathNot: `^${escape(component)}/` }, to: { path: `^${escape(component)}/`, - pathNot: `^${escape(component)}/(index|testing)\\.(ts|js)$`, + pathNot: `^${escape(component)}/(index|testing|definition)\\.(ts|js)$`, }, })); @@ -143,6 +143,38 @@ export default { "^src/daemon/(?!main\\.ts$)", { path: "^src/gateway/" }, ), + { + name: "definition-is-light", + comment: + "a module's definition.ts imports its own module only for types or by dynamic import, so reading the registry loads no module code", + severity: "error", + from: { path: "^src/([^/]+)/definition\\.ts$" }, + to: { + dependencyTypesNot: ["type-only", "dynamic-import"], + path: "^src/$1/", + pathNot: "^src/$1/definition\\.ts$", + }, + }, + { + name: "modules-imports-little", + comment: "src/modules imports only ports, contract and core, plus the bus for its types", + severity: "error", + from: { path: "^src/modules/", pathNot: linkTestFile }, + to: { path: "^src/", pathNot: "^src/(modules|ports|contract|core|bus)/" }, + }, + { + name: "modules-imports-bus-types-only", + comment: "src/modules imports the bus for its types only", + severity: "error", + from: { path: "^src/modules/", pathNot: linkTestFile }, + to: { path: "^src/bus/", dependencyTypesNot: ["type-only"] }, + }, + direction( + "not-modules", + "core, leasing, the drivers, the gateway and the contract import nothing from the optional-module host or its registry", + "^src/(core|leasing|drivers|gateway|contract)/", + { path: "^src/(modules|module-registry)/" }, + ), direction( "contract-imports-nothing", "the contract imports nothing outside src/contract/", diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 6fcec29e8..5270f5be7 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -6210,6 +6210,7 @@ async function startInMemoryDaemon(options: { function testConfig(): Config { return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/cli/index.ts b/src/cli/index.ts index a87f06842..bfa749b86 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,6 +6,7 @@ import type { z } from "zod"; import { EVENT_FILE_NAME, type EventEnvelope, eventKey, readEventFile } from "../bus/index.js"; import { loadConfig, type ConfigOverrides } from "../core/index.js"; +import { optionalModules } from "../module-registry/index.js"; import { IpcError, MemoryFilesystem, @@ -270,6 +271,8 @@ export interface CliEnvironmentPorts { readonly parentPid?: number; } +const moduleConfigEntries = optionalModules.map(({ config, name }) => ({ name, ...config })); + export function buildCliEnvironment( ports: CliEnvironmentPorts, env: NodeJS.ProcessEnv = process.env, @@ -282,7 +285,7 @@ export function buildCliEnvironment( // why: the daemon refuses to boot on a config `loadConfig` rejects and only its log says so; // loading the same file before the launch puts the `ConfigError` on the CLI's stderr. const autoLaunchIpc = new AutoLaunchIpcConnector(ipc, clock, launcher, async () => { - await loadConfig({ configPath, filesystem, systemStats }); + await loadConfig({ configPath, filesystem, modules: moduleConfigEntries, systemStats }); }); const connect = async ( @@ -338,6 +341,7 @@ export function buildCliEnvironment( await loadConfig({ configPath: "/config-set-validation.json", filesystem: new MemoryFilesystem(), + modules: moduleConfigEntries, overrides: merged as ConfigOverrides, systemStats, warn: (message) => { diff --git a/src/contract/AGENTS.md b/src/contract/AGENTS.md index 0aa93aa1c..2793a4dc1 100644 --- a/src/contract/AGENTS.md +++ b/src/contract/AGENTS.md @@ -55,7 +55,7 @@ Three identities: ## Protocol version -- Now `{ min: 37, max: 37 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`. +- Now `{ min: 38, max: 38 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`. - Every change to a contract shape bumps it by one in the same PR. Move `min` too, unless a compatibility path stays side by side. - `hello` picks the highest overlap. None → `PROTOCOL_VERSION_UNSUPPORTED` with both ranges. diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index 0a3668cb9..2f46dbe4d 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -10,7 +10,7 @@ export interface ProtocolRange { const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().int() }); /** Widen only once a second version is kept alive side by side with the first; a bump that leaves no shim behind moves both ends. */ -export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 37, max: 37 }; +export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 38, max: 38 }; const LEGACY_DAEMON_PROTOCOL_VERSION = 2; @@ -79,4 +79,16 @@ export const helloReplySchema = z.object({ }); /** The first path segment of every route the HTTP app registers itself; a module's name may not start with one. */ -export const CORE_ROUTE_SEGMENTS: readonly string[] = []; +export const CORE_ROUTE_SEGMENTS: readonly string[] = [ + "catalog", + "components", + "devices", + "events", + "healthz", + "lease-requests", + "leases", + "status", + "tokens", + "uplink", + "workers", +]; diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index b5b948be2..45e0db342 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -840,6 +840,10 @@ export const configSchema = z.object({ * runs in without inferring it from behaviour; `status.get`'s `daemon.mode` is the * agent-role answer to the same question. */ mode: z.enum(["worker", "gateway"]), + capabilities: z.record( + capabilityNameSchema, + z.object({ enabled: z.boolean() }).catchall(z.unknown()), + ), capacity: capacityConfigSchema, downloads: z.object({ policy: z.enum(["never", "on-request", "always"]), @@ -1071,9 +1075,12 @@ export const capabilitySchema = z }) .refine((entry) => (entry.state === "unavailable") === (entry.reason !== undefined)); +/** The most capabilities one daemon reports. */ +export const MAX_CAPABILITIES = 16; + export const capabilityListSchema = z .array(capabilitySchema) - .max(16) + .max(MAX_CAPABILITIES) .refine((list) => new Set(list.map((entry) => entry.name)).size === list.length); export function capabilityReason(text: string): string { diff --git a/src/core/cleanup/idle-destroy.test.ts b/src/core/cleanup/idle-destroy.test.ts index 260b3071f..9a66adf1b 100644 --- a/src/core/cleanup/idle-destroy.test.ts +++ b/src/core/cleanup/idle-destroy.test.ts @@ -6,6 +6,7 @@ import { idleDestroyRule } from "./idle-destroy.js"; const gibibyte = 1024 ** 3; const config: Config = { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/core/cleanup/idle-shutdown.test.ts b/src/core/cleanup/idle-shutdown.test.ts index 911c9138f..5cdc68656 100644 --- a/src/core/cleanup/idle-shutdown.test.ts +++ b/src/core/cleanup/idle-shutdown.test.ts @@ -4,6 +4,7 @@ import type { Config, RegistryView } from "../index.js"; import { idleShutdownRule } from "./idle-shutdown.js"; const config: Config = { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/core/config.ts b/src/core/config.ts index 288fb5675..e59aa9451 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -114,6 +114,9 @@ export type DownloadPolicy = "never" | "on-request" | "always"; export type DaemonMode = "worker" | "gateway"; export interface Config { + readonly capabilities: Readonly< + Record>> + >; /** See `DaemonMode`. Reported on `status.get`'s daemon block, which is how a client tells * the two apart. Default `"worker"`. */ readonly mode: DaemonMode; @@ -383,6 +386,7 @@ type Layer = Record; export async function loadConfig({ configPath = DEFAULT_CONFIG_PATH, filesystem, + modules = [], overrides, systemStats, warn = () => {}, @@ -398,7 +402,7 @@ export async function loadConfig({ // `http.enabled` defaults to, so it has to be known before anything is defaulted or warned // about. const mode = resolveMode([fromFile, fromOverrides]); - const validators = configValidators(strategy); + const validators = configValidators(strategy, modules); const fileConfig = normalizeLayer( validateConfigLayer(fromFile, "", warn, validators), @@ -412,16 +416,17 @@ export async function loadConfig({ ); const merged = mergeConfig( - mergeConfig(defaultConfig(systemStats, strategy, mode), fileConfig), + mergeConfig(defaultConfig(systemStats, strategy, mode, modules), fileConfig), overrideConfig, ) as unknown as Config; validateLeaseTtls(merged); validateAgentDeviceTtlCap(merged); validateEventLogBounds(merged); if (mode === "gateway") { - warnWorkerOnlyKeys([fromFile, fromOverrides], warn); + warnWorkerOnlyKeys([fromFile, fromOverrides], warn, modules); requireGatewayHttp(merged); } else { + runModuleChecks(merged, modules); // Worker-side only: a gateway ignores these keys entirely (it dials nobody), so pairing // them there would fail a start over a value nothing reads. validateGatewayMembership(merged); @@ -492,6 +497,7 @@ const GATEWAY_CONFIG_KEYS: readonly string[] = [ "eventLog", "gateway", "agentDevice", + "capabilities", ]; /** The `gateway.*` sub-keys a *worker* reads. A gateway ignores these three (it dials nobody); @@ -511,7 +517,11 @@ const WORKER_ONLY_AGENT_DEVICE_KEYS: readonly string[] = ["command"]; * Ignoring rather than failing is the deliberate choice the ADR makes, and it is what lets one * config file be copied between a worker and a gateway while only `mode` differs. */ -function warnWorkerOnlyKeys(layers: readonly unknown[], warn: Warn): void { +function warnWorkerOnlyKeys( + layers: readonly unknown[], + warn: Warn, + modules: readonly ModuleConfigEntry[], +): void { for (const layer of layers) { const object = requireObject(layer, "config"); for (const key of Object.keys(object)) { @@ -537,6 +547,34 @@ function warnWorkerOnlyKeys(layers: readonly unknown[], warn: Warn): void { `Ignoring "agentDevice.${key}": it names the program a worker runs, ` + `and this daemon runs in gateway mode.`, ); + warnWorkerOnlyModuleKeys(object, modules, warn); + } +} + +function warnWorkerOnlyModuleKeys( + layer: Record, + modules: readonly ModuleConfigEntry[], + warn: Warn, +): void { + const sections = layer["capabilities"]; + if (sections === undefined) return; + for (const [name, section] of Object.entries(requireObject(sections, "capabilities"))) { + const entry = modules.find((candidate) => candidate.name === name); + if (entry === undefined) continue; + for (const key of Object.keys(requireObject(section, `capabilities.${name}`))) { + if (key !== "enabled" && !entry.gatewayKeys.includes(key)) { + warn( + `Ignoring "capabilities.${name}.${key}": it configures a worker, ` + + `and this daemon runs in gateway mode.`, + ); + } + } + } +} + +function runModuleChecks(config: Config, modules: readonly ModuleConfigEntry[]): void { + for (const entry of modules) { + entry.check?.(config.capabilities[entry.name] as never, config); } } @@ -697,9 +735,16 @@ function defaultConfig( systemStats: SystemStats, strategy: CapacityStrategyName, mode: DaemonMode, + modules: readonly ModuleConfigEntry[], ): Config { return { mode, + capabilities: Object.fromEntries( + modules.map((entry) => [ + entry.name, + { ...(entry.defaults as Record), enabled: false }, + ]), + ) as Config["capabilities"], capacity: { strategy, config: defaultCapacityOptions(strategy, systemStats), @@ -832,8 +877,12 @@ const LEASE_IDENTITIES: readonly LeaseIdentity[] = ["reusable", "fresh"]; * 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 { +function configValidators( + strategy: CapacityStrategyName, + modules: readonly ModuleConfigEntry[], +): Record { return { + capabilities: capabilitiesValidator(modules), mode: stringUnion(DAEMON_MODES), gateway: objectValidator({ url: stringValue, @@ -934,6 +983,28 @@ function configValidators(strategy: CapacityStrategyName): Record { + const result: Record = {}; + for (const [name, section] of Object.entries(requireObject(value, path))) { + const sectionPath = `${path}.${name}`; + const entry = modules.find((candidate) => candidate.name === name); + if (entry === undefined) { + warn(`Unknown config key: "${sectionPath}"`); + continue; + } + const { enabled, ...rest } = requireObject(section, sectionPath); + result[name] = { + ...(entry.validator(rest, sectionPath, warn) as Record), + ...(enabled === undefined + ? {} + : { enabled: booleanValue(enabled, `${sectionPath}.enabled`) }), + }; + } + return result; + }; +} + /** * An operator's model preference per device class (ADR 0015 §4): for each class, the model * names to try first, most preferred first. A class with no key has none. diff --git a/src/core/doctor.test.ts b/src/core/doctor.test.ts index 4d1d9056a..abe584165 100644 --- a/src/core/doctor.test.ts +++ b/src/core/doctor.test.ts @@ -3224,6 +3224,7 @@ function withAdvisories( function config(stalledTransitionOverrides: Partial = {}): Config { return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/core/nuke.test.ts b/src/core/nuke.test.ts index b13333d43..95bb21a33 100644 --- a/src/core/nuke.test.ts +++ b/src/core/nuke.test.ts @@ -156,6 +156,7 @@ function sequence() { function config(): Config { return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/core/reaper.test.ts b/src/core/reaper.test.ts index 665184ac8..9ed5eca74 100644 --- a/src/core/reaper.test.ts +++ b/src/core/reaper.test.ts @@ -52,6 +52,7 @@ class MutableFreeDiskFilesystem extends MemoryFilesystem { function config(): Config { return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/daemon-protocol/index.test.ts b/src/daemon-protocol/index.test.ts index ca3503e3a..6e1011fbc 100644 --- a/src/daemon-protocol/index.test.ts +++ b/src/daemon-protocol/index.test.ts @@ -9,7 +9,7 @@ import { describe("daemon protocol", () => { it("keeps the current protocol version and newline framing", () => { - expect(DAEMON_PROTOCOL_VERSION).toBe(37); + expect(DAEMON_PROTOCOL_VERSION).toBe(38); expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n'); }); diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index 9b9102ebc..03bb296a7 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -53,7 +53,7 @@ An HTTP bind failure after a good start stops the daemon (`http-start-failed`). Principal: `hello.principal`, else `SIMLOCK_AGENT_ID`, else the daemon's pid. Both are fixed for the connection's life. 3. **Version** — ranges overlap → highest common version. `PROTOCOL_VERSION_RANGE` - is `{ min: 37, max: 37 }` (`src/contract/protocol.ts`). No overlap → the + is `{ min: 38, max: 38 }` (`src/contract/protocol.ts`). No overlap → the connection stays open, and every request but `daemon.stop` gets `PROTOCOL_VERSION_UNSUPPORTED`. 4. **Request** — one frame → one `dispatch()` call. While stopping: `DAEMON_STOPPING`. diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index f61ef6651..9829c8fba 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -3579,6 +3579,7 @@ function testConfig( }, ): Config { return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, gateway: { diff --git a/src/daemon/main.ts b/src/daemon/main.ts index ec3738ee8..15aee47a1 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -117,7 +117,8 @@ import { WebSocketUplinkListenerFactory, } from "../ports/uplink-websocket.js"; import { classifyError } from "./error-code.js"; -import type { OptionalModuleDefinition } from "../modules/index.js"; +import { createModuleHost, type OptionalModuleDefinition } from "../modules/index.js"; +import { optionalModules } from "../module-registry/index.js"; import { DaemonServer } from "./server.js"; import { DaemonEndpointHost } from "./connection-host.js"; import { AdminSecretManager } from "./admin-secret.js"; @@ -175,9 +176,14 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise ({ + name, + ...moduleConfig, + })), ...(options.configOverrides === undefined ? {} : { overrides: options.configOverrides }), systemStats, warn: (message) => configWarnings.push(message), @@ -329,19 +335,23 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise agentDeviceFindings(agentDevice.availability()), + optionalModules: () => [ + ...agentDeviceFindings(agentDevice.availability()), + ...moduleHost.findings(), + ], prerequisiteChecks, registry, systemStats, }); + const leaseEnvironment: Record = Object.assign( + {}, + ...drivers.map((driver) => driver.virtualLeaseEnvironment()), + ); const agentDevice = new AgentDeviceSupervisor({ baseEnvironment: process.env, clock, config: config.agentDevice, - driverEnvironment: Object.assign( - {}, - ...drivers.map((driver) => driver.virtualLeaseEnvironment()), - ), + driverEnvironment: leaseEnvironment, filesystem, healthProbe: options.agentDeviceHealth ?? new NodeAgentDeviceHealthProbe(), home: dataDirectory, @@ -362,6 +372,35 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise driver.platform), ) : undefined; + // why: a module's context is built before leasing exists, so `cancelPending` answers only once leasing does. + let cancelPendingRequest: ((requesterId: string) => Promise) | undefined; + const moduleHost = await createModuleHost({ + config, + context: { + cancelPending: (requesterId) => { + if (cancelPendingRequest === undefined) { + throw new Error("leasing is not ready yet"); + } + return cancelPendingRequest(requesterId); + }, + clock, + dataDirectory, + environment: process.env, + events: { subscribe: (event, handler) => eventBus.subscribe(event, handler) }, + filesystem, + leaseEnvironment, + nodePath: process.execPath, + nodeVersion: process.versions.node, + platforms: drivers.map((driver) => driver.platform), + processRunner, + processSupervisor, + tcpProbe, + }, + definitions: moduleDefinitions, + logger, + role: "worker", + wiredElsewhere: ["agent-device"], + }); // Builds leasing on top of core (ADR 0018 §2). const leasing = createLeasing({ clock, @@ -372,10 +411,14 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise leasing.cancelPending(requesterId); // Core declared ports only leasing can implement; this is the one place they are handed over, // before any request is admitted (ADR 0018 §2). core.connect(leasing.corePorts); @@ -488,6 +531,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise ({ state: "off" }) } : agentDeviceCapabilitySource(agentDeviceParts), + ...moduleHost.capabilitySources, ], capacity: core.capacityReader, warmPool: core.warmPoolReader, @@ -526,6 +570,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise leasing.corePorts.notifyAvailability()), @@ -552,6 +597,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise driver.dispose?.())); for (const [index, disposal] of disposals.entries()) { if (disposal.status === "rejected") { @@ -576,6 +622,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise leasing.cancelPending(id)); } + await moduleHost.beforeDrain(); await gatewayStarted.catch(() => undefined); await stopHttpGateway?.(); // Stopped with the other auxiliary frontends, and for the same reason: the uplink is a @@ -594,6 +641,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise { socketClaimed = true; + moduleHost.afterSocketClaimed(); // why: not awaited, since simlock's start never waits for agent-device, and a daemon that lost // the socket race never gets here, so it reaps nothing. if (options.agentDeviceUpstream === undefined) void agentDevice.start(); @@ -631,8 +679,8 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise undefined, - principal: "test-operator", - role: "admin", -} as const; -const agent = { - manageEventSubscription: () => undefined, - principal: "agent-1", - role: "agent", -} as const; -const ios = { - model: "iPhone 16", - osVersion: "26.5", - platform: "ios", - requesterId: "agent-1", -} as const; - -const daemons: DaemonServer[] = []; -const directories: string[] = []; -const servers: Server[] = []; - -afterEach(async () => { - await Promise.all(daemons.splice(0).map((daemon) => daemon.stop("test").catch(() => undefined))); - await Promise.all(servers.splice(0).map((server) => new Promise((done) => server.close(done)))); - await Promise.all( - directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })), - ); -}); - -async function freeLoopbackPort(): Promise { - const server = createServer(); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - await new Promise((resolve) => server.close(() => resolve())); - if (typeof address !== "object" || address === null) throw new Error("no port bound"); - return address.port; -} - -/** A driver that notes, in a shared list, when it is disposed or asked to reclaim a device. */ -class NotingDriver extends FakeDriver { - constructor( - options: ConstructorParameters[0], - readonly notes: string[], - ) { - super(options); - } - - override async reclaim(...args: Parameters) { - this.notes.push("driver reclaim"); - return super.reclaim(...args); - } - - async dispose(): Promise { - this.notes.push("driver dispose"); - } -} - -interface Rig { - readonly daemon: DaemonServer; - readonly clock: FakeClock; - readonly sink: MemoryLogSink; - readonly directory: string; - readonly notes: string[]; - readonly origin: string | undefined; - readonly driver: NotingDriver; -} - -interface RigOptions { - /** The modules the daemon is given, in registry order. */ - readonly modules: readonly OptionalModuleDefinition[]; - /** The module names turned on with `capabilities..enabled`. */ - readonly on?: readonly string[]; - readonly http?: boolean; - readonly extra?: Partial; - readonly overrides?: Record; - readonly notes?: string[]; -} - -function definitions(...modules: FakeModule[]): OptionalModuleDefinition[] { - return modules.map((module) => module.definition as OptionalModuleDefinition); -} - -async function prepare(options: RigOptions) { - const directory = await mkdtemp(join(tmpdir(), "simlock-modules-")); - directories.push(directory); - const clock = new FakeClock(1_000); - const sink = new MemoryLogSink(); - const notes = options.notes ?? []; - const driver = new NotingDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }, notes); - const port = options.http === true ? await freeLoopbackPort() : undefined; - const startOptions = { - clock, - configOverrides: { - capabilities: Object.fromEntries((options.on ?? []).map((name) => [name, { enabled: true }])), - ...(port === undefined ? {} : { http: { enabled: true, host: "127.0.0.1", port } }), - ...options.overrides, - }, - dataDirectory: directory, - drivers: [driver], - filesystem: new MemoryFilesystem(), - hostLoadProbe: null, - logger: new JsonLinesLogger({ clock, level: "debug", sink }), - optionalModules: options.modules, - socketPath: join(directory, "daemon.sock"), - statePath: join(directory, "state.json"), - version: "1.2.3", - ...options.extra, - } as StartDaemonOptions; - return { - clock, - directory, - driver, - notes, - origin: port === undefined ? undefined : `http://127.0.0.1:${String(port)}`, - sink, - startOptions, - }; -} - -async function start(options: RigOptions): Promise { - const prepared = await prepare(options); - const daemon = await startDaemon(prepared.startOptions); - daemons.push(daemon); - return { ...prepared, daemon }; -} - -async function agentHeaders(daemon: DaemonServer): Promise> { - const { secret } = await daemon.dispatch("token.create", { label: "a", role: "agent" }, admin); - return { authorization: `Bearer ${secret}` }; -} - -async function capabilities(daemon: DaemonServer) { - const status = (await daemon.dispatch("status.get", {}, agent)) as { - readonly capabilities?: readonly { name: string; state: string; reason?: string }[]; - }; - return status.capabilities ?? []; -} - -function recordsOf(sink: MemoryLogSink, message: string) { - return sink.records.filter((record) => record.message === message); -} - -async function leaseAndRelease(daemon: DaemonServer): Promise { - const grant = (await daemon.dispatch("lease.request", ios, agent)) as { lease: { id: string } }; - await daemon.dispatch("lease.release", { leaseId: grant.lease.id }, agent); - return grant.lease.id; -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -describe("optional modules: turning on", () => { - it("a module with no capabilities section, or with enabled false, is reported off in status.get, and its loadWorker is never called", async () => { - const bare = fakeModule({ name: "alpha" }); - const disabled = fakeModule({ name: "bravo" }); - const { daemon } = await start({ - modules: definitions(bare, disabled), - overrides: { capabilities: { bravo: { enabled: false } } }, - }); - - expect(await capabilities(daemon)).toEqual( - expect.arrayContaining([ - { name: "alpha", state: "off" }, - { name: "bravo", state: "off" }, - ]), - ); - expect({ alpha: bare.loads, bravo: disabled.loads }).toEqual({ alpha: 0, bravo: 0 }); - }); - - it("a module turned on is loaded once and reports the state its capability source answers", async () => { - const module = fakeModule({ name: "alpha", state: "busy" }); - const { daemon } = await start({ modules: definitions(module), on: ["alpha"] }); - - expect(await capabilities(daemon)).toContainEqual({ name: "alpha", state: "busy" }); - expect({ loads: module.loads, creates: module.creates }).toEqual({ creates: 1, loads: 1 }); - }); - - it("the capability list is agent-device's entry first, then registered modules in registry order", async () => { - const { daemon } = await start({ - extra: { agentDeviceUpstream: new FakeAgentDevice() }, - modules: definitions(fakeModule({ name: "zulu" }), fakeModule({ name: "alpha" })), - on: ["zulu", "alpha"], - overrides: { agentDevice: { enabled: true } }, - }); - - expect((await capabilities(daemon)).map((entry) => entry.name)).toEqual([ - "agent-device", - "zulu", - "alpha", - ]); - }); - - it("config.get returns capabilities with each module's section", async () => { - const { daemon } = await start({ - modules: definitions(fakeModule({ name: "alpha" })), - on: ["alpha"], - }); - - const config = (await daemon.dispatch("config.get", {}, admin)) as { - readonly capabilities?: unknown; - }; - - expect(config.capabilities).toEqual({ alpha: { enabled: true, greeting: "hello" } }); - }); -}); - -describe("optional modules: HTTP routes", () => { - const handler = (request: Request, identity: { readonly role: string }) => - Promise.resolve(Response.json({ path: new URL(request.url).pathname, role: identity.role })); - - it("a module turned on serves /v1/ and /v1//* through its handler, behind agent auth", async () => { - const { daemon, origin } = await start({ - http: true, - modules: definitions(fakeModule({ mount: () => handler, name: "alpha" })), - on: ["alpha"], - }); - const headers = await agentHeaders(daemon); - - const root = await fetch(`${origin}/v1/alpha`, { headers }); - const deep = await fetch(`${origin}/v1/alpha/deep/x`, { headers }); - const anonymous = await fetch(`${origin}/v1/alpha`); - - expect(await root.json()).toEqual({ path: "/v1/alpha", role: "agent" }); - expect(await deep.json()).toEqual({ path: "/v1/alpha/deep/x", role: "agent" }); - expect(anonymous.status).toBe(401); - }); - - it("a module named like a core route's first segment (status) fails the start, with HTTP off too", async () => { - const accepted = await start({ modules: definitions(fakeModule({ name: "stats" })) }); - expect(accepted.daemon).toBeDefined(); - - await expect(start({ modules: definitions(fakeModule({ name: "status" })) })).rejects.toThrow( - "status", - ); - }); - - it("with agent-device on and a fake module registered, both modules' routes and relay targets answer, and both capabilities are listed", async () => { - const seen: string[] = []; - const { daemon, origin } = await start({ - extra: { agentDeviceUpstream: new FakeAgentDevice() }, - http: true, - modules: definitions( - fakeModule({ - mount: () => handler, - name: "alpha", - relayTarget: (_request, tenant) => { - seen.push(tenant); - return Promise.resolve(new Response("alpha")); - }, - }), - ), - on: ["alpha"], - overrides: { agentDevice: { enabled: true } }, - }); - const headers = await agentHeaders(daemon); - const uplink = await openUplink(daemon); - - const alphaRoute = await fetch(`${origin}/v1/alpha/ping`, { headers }); - const agentDeviceRoute = await fetch(`${origin}/v1/agent-device/health`, { headers }); - const alphaRelay = await openRelay(uplink, relayInput({ target: "alpha" })); - const agentDeviceRelay = await settleWithin( - (await openRelay(uplink, relayInput({ target: "agent-device" }))).result, - ); - const nobodyRelay = await settleWithin( - (await openRelay(uplink, relayInput({ target: "nobody" }))).result, - ); - - expect([alphaRoute.status, agentDeviceRoute.status]).toEqual([200, 200]); - expect(seen).toEqual(["gw.g1.alice"]); - expect((await alphaRelay.body()).state).toBe("fulfilled"); - expect(nobodyRelay).toMatchObject({ code: "RELAY_TARGET_MISSING", state: "rejected" }); - expect(agentDeviceRelay).not.toMatchObject({ code: "RELAY_TARGET_MISSING" }); - expect((await capabilities(daemon)).map((entry) => entry.name)).toEqual([ - "agent-device", - "alpha", - ]); - }); -}); - -describe("optional modules: relay targets", () => { - it("a relay request to a module's name reaches its relay target with the tenant and lease id", async () => { - const calls: { tenant: string; leaseId: string | undefined }[] = []; - const { daemon } = await start({ - modules: definitions( - fakeModule({ - name: "alpha", - relayTarget: (_request, tenant, leaseId) => { - calls.push({ leaseId, tenant }); - return Promise.resolve(new Response("from alpha")); - }, - }), - ), - on: ["alpha"], - }); - const uplink = await openUplink(daemon); - - const relay = await openRelay( - uplink, - relayInput({ leaseId: "lease-9", target: "alpha", tenant: "gw.g1.bob" }), - ); - const body = await relay.body(); - - expect(calls).toEqual([{ leaseId: "lease-9", tenant: "gw.g1.bob" }]); - expect(body.state === "fulfilled" ? Buffer.from(body.value).toString() : body.state).toBe( - "from alpha", - ); - }); - - it("a test's relayTargets option still wins over a module's relay target", async () => { - const { daemon } = await start({ - extra: { relayTargets: { alpha: () => Promise.resolve(new Response("from the test")) } }, - modules: definitions( - fakeModule({ - name: "alpha", - relayTarget: () => Promise.resolve(new Response("from alpha")), - }), - ), - on: ["alpha"], - }); - const uplink = await openUplink(daemon); - - const body = await (await openRelay(uplink, relayInput({ target: "alpha" }))).body(); - - expect(body.state === "fulfilled" ? Buffer.from(body.value).toString() : body.state).toBe( - "from the test", - ); - }); -}); - -describe("optional modules: doctor findings", () => { - it("a fake module's finding reaches doctor.run as module-unavailable with module, message and remedy; a second module whose findings() throws loses only its own", async () => { - const { daemon, sink } = await start({ - modules: definitions( - fakeModule({ - findings: () => [{ message: "it is down", remedy: "bring it up" }], - name: "alpha", - }), - fakeModule({ - findings: () => { - throw new Error("boom"); - }, - name: "bravo", - }), - ), - on: ["alpha", "bravo"], - }); - - const report = (await daemon.dispatch("doctor.run", {}, admin)) as { - readonly findings: readonly { readonly kind: string }[]; - }; - - expect(report.findings).toContainEqual({ - kind: "module-unavailable", - message: "it is down", - module: "alpha", - remedy: "bring it up", - }); - expect(report.findings.filter((finding) => finding.kind === "module-unavailable")).toHaveLength( - 1, - ); - expect( - recordsOf(sink, "Could not read optional module findings").map((record) => record.fields), - ).toEqual([{ message: "boom", module: "bravo" }]); - }); -}); - -describe("optional modules: a start that cannot go on", () => { - const cases: readonly { - readonly title: string; - readonly good: () => FakeModule[]; - readonly bad: () => FakeModule[]; - readonly names: readonly string[]; - readonly overrides?: Record; - }[] = [ - { - bad: () => [fakeModule({ capabilityName: "beta", name: "alpha" })], - good: () => [fakeModule({ name: "alpha" })], - names: ["alpha", "beta"], - title: "a capability name that differs from the definition's", - }, - { - bad: () => [fakeModule({ name: "alpha" }), fakeModule({ name: "alpha" })], - good: () => [fakeModule({ name: "alpha" }), fakeModule({ name: "bravo" })], - names: ["alpha"], - title: "two definitions with one name", - }, - { - bad: () => [fakeModule({ name: "agent-device" })], - good: () => [fakeModule({ name: "agent-devices" })], - names: ["agent-device"], - title: "a definition named agent-device while it is wired by hand", - }, - { - bad: () => [fakeModule({ name: "Bad_Name" })], - good: () => [fakeModule({ name: "good-name" })], - names: ["Bad_Name"], - title: "a name that fails the capability name check", - }, - { - bad: () => Array.from({ length: 16 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), - good: () => Array.from({ length: 15 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), - names: ["16"], - title: "16 fake modules plus agent-device wired by hand (17 capabilities)", - }, - { - bad: () => [fakeModule({ loadError: new Error("no such file"), name: "alpha" })], - good: () => [fakeModule({ name: "alpha" })], - names: ["alpha", "no such file"], - title: "a loadWorker() that throws", - }, - ]; - - it.each(cases)("the start fails, naming the module, for $title", async ({ bad, good, names }) => { - const turnOn = (modules: FakeModule[]) => modules.map((m) => m.definition.name); - const goodModules = good(); - await start({ modules: definitions(...goodModules), on: turnOn(goodModules) }); - - const failure = await start({ modules: definitions(...bad()), on: turnOn(bad()) }).then( - () => undefined, - (error: unknown) => error as Error, - ); - - expect(failure).toBeInstanceOf(Error); - for (const name of names) expect(failure?.message).toContain(name); - }); - - it("a module that calls ctx.cancelPending in createWorker fails the start, naming the module", async () => { - const quiet = fakeModule({ name: "alpha" }); - await start({ modules: definitions(quiet), on: ["alpha"] }); - - const failure = await start({ - modules: definitions( - fakeModule({ name: "alpha", onCreate: (ctx) => void ctx.cancelPending("agent-1") }), - ), - on: ["alpha"], - }).then( - () => undefined, - (error: unknown) => error as Error, - ); - - expect(failure).toBeInstanceOf(Error); - expect(failure?.message).toContain("alpha"); - expect(failure?.message).toContain("leasing is not ready yet"); - }); - - it("when the second of two modules throws in createWorker, the first module's afterSocketClaimed never runs", async () => { - const claimed = vi.fn(); - const first = fakeModule({ hooks: { afterSocketClaimed: claimed }, name: "alpha" }); - const failure = await start({ - modules: definitions( - first, - fakeModule({ - name: "bravo", - onCreate: () => { - throw new Error("bravo broke"); - }, - }), - ), - on: ["alpha", "bravo"], - }).then( - () => undefined, - (error: unknown) => error as Error, - ); - await sleep(50); - - expect(failure).toBeInstanceOf(Error); - expect(failure?.message).toContain("bravo"); - expect({ claimed: claimed.mock.calls.length, created: first.creates }).toEqual({ - claimed: 0, - created: 1, - }); - }); - - it("when the HTTP gateway fails to bind after the socket is claimed, the daemon stops and each started module's beforeDrain and beforeDriversDispose run", async () => { - const taken = createServer(); - servers.push(taken); - await new Promise((resolve) => taken.listen(0, "127.0.0.1", resolve)); - const { port } = taken.address() as { port: number }; - const notes: string[] = []; - const hooks: WorkerHooks = { - beforeDrain: () => { - notes.push("drain"); - return Promise.resolve(); - }, - beforeDriversDispose: () => { - notes.push("dispose"); - return Promise.resolve(); - }, - }; - - const outcome = await prepare({ - modules: definitions(fakeModule({ hooks, name: "alpha" })), - notes, - on: ["alpha"], - overrides: { http: { enabled: true, host: "127.0.0.1", port } }, - }).then((prepared) => - startDaemon(prepared.startOptions).then( - () => "started", - () => "rejected", - ), - ); - await vi.waitFor(() => expect(notes).toContain("dispose")); - - expect(outcome).toBe("rejected"); - expect(notes.filter((note) => note === "drain" || note === "dispose")).toEqual([ - "drain", - "dispose", - ]); - }); -}); - -describe("optional modules: the context a module receives", () => { - it("has a logger named after the module, and events.subscribe delivers a bus event", async () => { - const delivered: string[] = []; - const { daemon, sink } = await start({ - modules: definitions( - fakeModule({ - name: "alpha", - onCreate: (ctx) => { - ctx.logger.info("alpha says hello"); - ctx.events.subscribe("lease.released", (envelope) => { - delivered.push(envelope.payload.leaseId); - }); - }, - }), - ), - on: ["alpha"], - }); - - const leaseId = await leaseAndRelease(daemon); - - expect(recordsOf(sink, "alpha says hello").map((record) => record.module)).toEqual([ - "daemon.alpha", - ]); - expect(delivered).toEqual([leaseId]); - }); -}); - -describe("optional modules: afterSocketClaimed", () => { - it("runs once after the socket is claimed, and the start does not wait for it", async () => { - let socketPath = ""; - const seen: boolean[] = []; - const never = new Promise(() => undefined); - const prepared = await prepare({ - modules: definitions( - fakeModule({ - hooks: { - afterSocketClaimed: () => { - seen.push(existsSync(socketPath)); - return never; - }, - }, - name: "alpha", - }), - ), - on: ["alpha"], - }); - socketPath = join(prepared.directory, "daemon.sock"); - - const started = await settleWithin(startDaemon(prepared.startOptions), 5_000); - if (started.state === "fulfilled") daemons.push(started.value); - await sleep(50); - - expect(started.state).toBe("fulfilled"); - expect(seen).toEqual([true]); - }); - - it("never runs in a daemon that lost the socket race", async () => { - const claimed = vi.fn(); - const filesystem = new MemoryFilesystem(); - const directory = await mkdtemp(join(tmpdir(), "simlock-modules-race-")); - directories.push(directory); - const options = (modules: readonly OptionalModuleDefinition[]) => - ({ - clock: new FakeClock(1_000), - configOverrides: { capabilities: { alpha: { enabled: true } } }, - dataDirectory: directory, - drivers: [ - new FakeDriver({ - availableOsVersions: ["26.5"], - clock: new FakeClock(1_000), - platform: "ios", - }), - ], - filesystem, - logger: new JsonLinesLogger({ - clock: new FakeClock(1_000), - level: "debug", - sink: new MemoryLogSink(), - }), - optionalModules: modules, - statePath: join(directory, "state.json"), - version: "1.2.3", - }) as StartDaemonOptions; - const first = await startDaemon(options([])); - daemons.push(first); - - const loser = fakeModule({ hooks: { afterSocketClaimed: claimed }, name: "alpha" }); - const second = await startDaemon(options(definitions(loser))).then( - () => "started", - () => "rejected", - ); - await sleep(50); - - expect({ claimed: claimed.mock.calls.length, created: loser.creates, second }).toEqual({ - claimed: 0, - created: 1, - second: "rejected", - }); - }); - - it.each([ - [ - "throws", - () => { - throw new Error("hook broke"); - }, - ], - ["rejects", () => Promise.reject(new Error("hook broke"))], - ] as const)( - "an afterSocketClaimed that %s is logged as failed, and the daemon still starts", - async (_how, hook) => { - const { sink } = await start({ - modules: definitions(fakeModule({ hooks: { afterSocketClaimed: hook }, name: "alpha" })), - on: ["alpha"], - }); - - await vi.waitFor(() => - expect( - recordsOf(sink, "Optional module hook failed").map((record) => record.fields), - ).toEqual([{ hook: "afterSocketClaimed", message: "hook broke", module: "alpha" }]), - ); - }, - ); -}); - -describe("optional modules: beforeDeviceReclaim", () => { - it("a release reclaims the device when no turned-on module has beforeDeviceReclaim", async () => { - const notes: string[] = []; - const { daemon } = await start({ - modules: definitions(fakeModule({ name: "alpha" })), - notes, - on: ["alpha"], - }); - - await leaseAndRelease(daemon); - - await vi.waitFor(() => expect(notes).toContain("driver reclaim")); - }); - - it("beforeDeviceReclaim runs after a release commits and before the device's reclaim starts", async () => { - const notes: string[] = []; - let live: DaemonServer | undefined; - const { daemon } = await start({ - modules: definitions( - fakeModule({ - hooks: { - beforeDeviceReclaim: async (lease) => { - const listed = (await live?.dispatch("lease.list", {}, admin)) as { - leases: { id: string }[]; - }; - notes.push( - listed.leases.some((entry) => entry.id === lease.leaseId) - ? "lease still listed" - : "lease gone", - ); - await sleep(30); - notes.push("hook done"); - }, - }, - name: "alpha", - }), - ), - notes, - on: ["alpha"], - }); - live = daemon; - - await leaseAndRelease(daemon); - await vi.waitFor(() => expect(notes).toContain("driver reclaim")); - - expect(notes).toEqual(["lease gone", "hook done", "driver reclaim"]); - }); -}); - -describe("optional modules: stop hooks", () => { - it("beforeDrain finishes before the HTTP gateway stops and before leasing's settle starts", async () => { - let origin: string | undefined; - let moduleLog: { info(message: string): void } | undefined; - const notes: string[] = []; - const rig = await start({ - http: true, - modules: definitions( - fakeModule({ - hooks: { - beforeDrain: async () => { - await sleep(50); - const health = await fetch(`${origin}/v1/healthz`).then( - (response) => String(response.status), - () => "refused", - ); - notes.push(`gateway ${health}`); - moduleLog?.info("drain done"); - }, - }, - name: "alpha", - onCreate: (ctx) => { - moduleLog = ctx.logger; - }, - }), - ), - on: ["alpha"], - }); - origin = rig.origin; - - await rig.daemon.stop("test"); - - const messages = rig.sink.records.map((record) => record.message); - expect(notes).toEqual(["gateway 200"]); - expect(messages.indexOf("drain done")).toBeGreaterThanOrEqual(0); - expect(messages.indexOf("drain done")).toBeLessThan(messages.indexOf("Daemon stopping")); - }); - - it("beforeDriversDispose finishes before any driver's dispose starts", async () => { - const notes: string[] = []; - const { daemon } = await start({ - modules: definitions( - fakeModule({ - hooks: { - beforeDriversDispose: async () => { - await sleep(30); - notes.push("hook done"); - }, - }, - name: "alpha", - }), - ), - notes, - on: ["alpha"], - }); - - await daemon.stop("test"); - - expect(notes).toEqual(["hook done", "driver dispose"]); - }); - - it("with two modules whose beforeDrain hangs and settles at once, both start together; on a fake clock the step ends at 30 s with only the hanging one logged as ran out of time, and the stop goes on", async () => { - const started: string[] = []; - const { clock, daemon, sink } = await start({ - modules: definitions( - fakeModule({ - hooks: { - beforeDrain: () => { - started.push("hangs"); - return new Promise(() => undefined); - }, - }, - name: "hangs", - }), - fakeModule({ - hooks: { - beforeDrain: () => { - started.push("quick"); - return Promise.resolve(); - }, - }, - name: "quick", - }), - ), - on: ["hangs", "quick"], - }); - let stopped = false; - - const stopping = daemon.stop("test").then(() => { - stopped = true; - }); - await vi.waitFor(() => expect([...started].sort()).toEqual(["hangs", "quick"])); - clock.advance(29_999); - await sleep(30); - const stoppedEarly = stopped; - clock.advance(1); - await stopping; - - expect(stoppedEarly).toBe(false); - expect( - recordsOf(sink, "Optional module hook ran out of time").map((record) => record.fields), - ).toEqual([{ hook: "beforeDrain", module: "hangs" }]); - }); - - it("a beforeDrain that throws is logged as failed, and the HTTP gateway still stops", async () => { - const { daemon, origin, sink } = await start({ - http: true, - modules: definitions( - fakeModule({ - hooks: { - beforeDrain: () => Promise.reject(new Error("drain broke")), - }, - name: "alpha", - }), - ), - on: ["alpha"], - }); - expect((await fetch(`${origin}/v1/healthz`)).status).toBe(200); - - await daemon.stop("test"); - const afterStop = await fetch(`${origin}/v1/healthz`).then( - () => "answered", - () => "refused", - ); - - expect(recordsOf(sink, "Optional module hook failed").map((record) => record.fields)).toEqual([ - { hook: "beforeDrain", message: "drain broke", module: "alpha" }, - ]); - expect(afterStop).toBe("refused"); - }); - - it("a beforeDriversDispose that throws is logged, and the drivers are still disposed", async () => { - const notes: string[] = []; - const { daemon, sink } = await start({ - modules: definitions( - fakeModule({ - hooks: { beforeDriversDispose: () => Promise.reject(new Error("dispose broke")) }, - name: "alpha", - }), - ), - notes, - on: ["alpha"], - }); - - await daemon.stop("test"); - - expect(recordsOf(sink, "Optional module hook failed").map((record) => record.fields)).toEqual([ - { hook: "beforeDriversDispose", message: "dispose broke", module: "alpha" }, - ]); - expect(notes).toContain("driver dispose"); - }); - - it("a beforeDriversDispose that runs past 30 s on a fake clock is logged, and the drivers are still disposed", async () => { - const notes: string[] = []; - const { clock, daemon, sink } = await start({ - modules: definitions( - fakeModule({ - hooks: { - beforeDriversDispose: () => { - notes.push("hook started"); - return new Promise(() => undefined); - }, - }, - name: "alpha", - }), - ), - notes, - on: ["alpha"], - }); - - const stopping = daemon.stop("test"); - await vi.waitFor(() => expect(notes).toContain("hook started")); - clock.advance(30_000); - await stopping; - - expect( - recordsOf(sink, "Optional module hook ran out of time").map((record) => record.fields), - ).toEqual([{ hook: "beforeDriversDispose", module: "alpha" }]); - expect(notes).toContain("driver dispose"); - }); -}); diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 7076eba8f..6fd271af0 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -206,8 +206,9 @@ describe("DaemonServer", () => { [34, "cannot read status.get's load block"], [35, "cannot read a worker view's load"], [36, "cannot read the module-unavailable finding in doctor.run"], + [37, "cannot read config.get's capabilities"], ])( - "answers PROTOCOL_VERSION_UNSUPPORTED, naming protocol 37, to a client on protocol %i, which %s", + "answers PROTOCOL_VERSION_UNSUPPORTED, naming protocol 38, to a client on protocol %i, which %s", async (version) => { const harness = await createHarness(); const previous = await createClient(harness.socketPath); @@ -217,7 +218,7 @@ describe("DaemonServer", () => { ).resolves.toMatchObject({ error: { code: "PROTOCOL_VERSION_UNSUPPORTED", - details: { client: { min: version, max: version }, daemon: { min: 37, max: 37 } }, + details: { client: { min: version, max: version }, daemon: { min: 38, max: 38 } }, }, ok: false, }); @@ -3570,6 +3571,7 @@ function testConfig( iosMaxDevices = 1, ): Config { return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index 51306cee9..73246f2b8 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -30,6 +30,7 @@ import { WorkerRegistry } from "./worker-registry.js"; const gatewayConfig = { agentDevice: { command: "agent-device", enabled: false }, + capabilities: {}, capacity: { strategy: "fixed" as const, config: { maxRunning: 4 } }, diskPressure: { freeBytesThreshold: 1 }, downloads: { acceptAndroidLicenses: false, policy: "on-request" as const, timeoutMs: 1 }, diff --git a/src/gateway/service.test.ts b/src/gateway/service.test.ts index 215665459..0695dbf6a 100644 --- a/src/gateway/service.test.ts +++ b/src/gateway/service.test.ts @@ -1234,7 +1234,7 @@ describe("GatewayService", () => { min: PROTOCOL_VERSION_RANGE.max - 1, max: PROTOCOL_VERSION_RANGE.max - 1, }; - expect(PROTOCOL_VERSION_RANGE.max).toBe(37); + expect(PROTOCOL_VERSION_RANGE.max).toBe(38); expect(negotiateProtocolVersion(PROTOCOL_VERSION_RANGE, previous)).toBeUndefined(); const harness = fleet(); await harness.service.start(); diff --git a/src/http/test-fakes.ts b/src/http/test-fakes.ts index c7e595435..b56fd1824 100644 --- a/src/http/test-fakes.ts +++ b/src/http/test-fakes.ts @@ -31,6 +31,7 @@ export function testConfig( mode: Config["mode"] = "worker", ): Config { return { + capabilities: {}, mode, agentDevice: { command: "agent-device", enabled: false }, gateway: { diff --git a/src/leasing/acquisition-planner.test.ts b/src/leasing/acquisition-planner.test.ts index b4614cab3..cf8d105ca 100644 --- a/src/leasing/acquisition-planner.test.ts +++ b/src/leasing/acquisition-planner.test.ts @@ -15,6 +15,7 @@ import { createCapacityStrategy } from "../core/testing.js"; const gibibyte = 1024 ** 3; const spec = { physical: false, model: "iPhone 16", osVersion: "26.5", platform: "ios" } as const; const config: Config = { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/leasing/create-leasing.test.ts b/src/leasing/create-leasing.test.ts index 92266714b..2e83e7a69 100644 --- a/src/leasing/create-leasing.test.ts +++ b/src/leasing/create-leasing.test.ts @@ -44,6 +44,7 @@ function config( warmPool: Partial = {}, ): Config { return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/leasing/lease-acquisition-coordinator.test.ts b/src/leasing/lease-acquisition-coordinator.test.ts index 0e2dc7baf..91d3848bf 100644 --- a/src/leasing/lease-acquisition-coordinator.test.ts +++ b/src/leasing/lease-acquisition-coordinator.test.ts @@ -62,6 +62,7 @@ const request = { function config(maxDevices = 1, maxRunning = 1): Config { return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/leasing/lease-end-participants.test.ts b/src/leasing/lease-end-participants.test.ts index ec5249d11..e0072ce2b 100644 --- a/src/leasing/lease-end-participants.test.ts +++ b/src/leasing/lease-end-participants.test.ts @@ -111,6 +111,7 @@ interface EngineOptions { function config(options: EngineOptions): Config { const ios = options.ios ?? { maxDevices: 1, maxRunning: 1 }; return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/leasing/lease-health-monitor.test.ts b/src/leasing/lease-health-monitor.test.ts index 628ff2a00..df3dba278 100644 --- a/src/leasing/lease-health-monitor.test.ts +++ b/src/leasing/lease-health-monitor.test.ts @@ -23,6 +23,7 @@ const spec = { physical: false, model: "iPhone 16", osVersion: "26.5", platform: function config(overrides: Partial = {}): Config { return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/leasing/physical-device-watcher.test.ts b/src/leasing/physical-device-watcher.test.ts index 467cd3102..ddaa25486 100644 --- a/src/leasing/physical-device-watcher.test.ts +++ b/src/leasing/physical-device-watcher.test.ts @@ -45,6 +45,7 @@ interface HarnessOptions { function config(options: HarnessOptions): Config { const quarantineBackoffMs = options.quarantineBackoffMs ?? 30_000; return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/leasing/physical-leasing.test.ts b/src/leasing/physical-leasing.test.ts index 6e93b4a34..53568727f 100644 --- a/src/leasing/physical-leasing.test.ts +++ b/src/leasing/physical-leasing.test.ts @@ -33,6 +33,7 @@ const virtualRequest = { function config(leaseOverrides: object = {}): Config { return { + capabilities: {}, mode: "worker", agentDevice: { command: "agent-device", enabled: false }, exec: { timeoutMs: 600_000 }, diff --git a/src/modules/host.ts b/src/modules/host.ts new file mode 100644 index 000000000..b48bf8c2a --- /dev/null +++ b/src/modules/host.ts @@ -0,0 +1,200 @@ +import { CORE_ROUTE_SEGMENTS, MAX_CAPABILITIES, capabilityNameSchema } from "../contract/index.js"; +import type { Logger } from "../ports/index.js"; +import type { + ModuleCapabilitySource, + ModuleHost, + ModuleHostOptions, + ModuleLeaseEndParticipant, + ModuleMount, + ModuleRelayTarget, + MountDeps, + OptionalModuleDefinition, + WorkerHooks, + WorkerModule, +} from "./types.js"; + +const HOOK_DEADLINE_MS = 30_000; + +interface Started { + readonly name: string; + readonly worker: WorkerModule; +} + +type StopHook = "beforeDrain" | "beforeDriversDispose"; + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function checkNames( + definitions: readonly OptionalModuleDefinition[], + wiredElsewhere: readonly string[], +): void { + const seen = new Set(wiredElsewhere); + for (const { name } of definitions) { + if (!capabilityNameSchema.safeParse(name).success) { + throw new Error(`Optional module "${name}": the name is not a valid capability name`); + } + if (seen.has(name)) { + throw new Error(`Optional module "${name}": the name is already taken`); + } + seen.add(name); + if (CORE_ROUTE_SEGMENTS.includes(name)) { + throw new Error(`Optional module "${name}": the name is reserved for a core route`); + } + } + const total = definitions.length + wiredElsewhere.length; + if (total > MAX_CAPABILITIES) { + const last = definitions[definitions.length - 1]?.name ?? ""; + throw new Error( + `Optional module "${last}": ${String(total)} capabilities exceed the limit of ${String(MAX_CAPABILITIES)}`, + ); + } +} + +async function createOne( + definition: OptionalModuleDefinition, + options: ModuleHostOptions, +): Promise { + const { name } = definition; + let worker: WorkerModule; + try { + const loaded = await definition.loadWorker(); + const section = Object.fromEntries( + Object.entries(options.config.capabilities[name] ?? {}).filter(([key]) => key !== "enabled"), + ); + worker = loaded.createWorker({ + ...options.context, + config: section, + daemonConfig: options.config, + logger: options.logger.child(name), + name, + }); + } catch (error: unknown) { + throw new Error(`Optional module "${name}" failed to start: ${messageOf(error)}`, { + cause: error, + }); + } + if (worker.capability.name !== name) { + throw new Error( + `Optional module "${name}" reports a capability named "${worker.capability.name}"`, + ); + } + return worker; +} + +async function runHook( + logger: Logger, + module: string, + hook: keyof WorkerHooks, + call: () => unknown, +): Promise { + try { + await call(); + } catch (error: unknown) { + logger.error("Optional module hook failed", { hook, message: messageOf(error), module }); + } +} + +export async function createModuleHost(options: ModuleHostOptions): Promise { + const { definitions, logger, wiredElsewhere } = options; + checkNames(definitions, wiredElsewhere); + + const capabilitySources: ModuleCapabilitySource[] = []; + const started: Started[] = []; + for (const definition of definitions) { + if (options.config.capabilities[definition.name]?.enabled !== true) { + capabilitySources.push({ + name: definition.name, + read: () => Promise.resolve({ state: "off" }), + }); + continue; + } + const worker = await createOne(definition, options); + started.push({ name: definition.name, worker }); + capabilitySources.push(worker.capability); + } + + const relayTargets: Record = {}; + for (const { name, worker } of started) { + if (worker.relayTarget !== undefined) relayTargets[name] = worker.relayTarget; + } + + const leaseEndParticipants: ModuleLeaseEndParticipant[] = []; + for (const { worker } of started) { + const reclaim = worker.hooks?.beforeDeviceReclaim; + if (reclaim !== undefined) { + leaseEndParticipants.push({ leaseEnded: (ended, signal) => reclaim(ended, signal) }); + } + } + + const runStopStep = async (hook: StopHook): Promise => { + const running = new Set(); + const calls = started.flatMap(({ name, worker }) => { + const call = worker.hooks?.[hook]; + if (call === undefined) return []; + running.add(name); + return [ + runHook(logger, name, hook, () => call()).finally(() => { + running.delete(name); + }), + ]; + }); + if (calls.length === 0) return; + const { clock } = options.context; + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = clock.setTimer(HOOK_DEADLINE_MS, resolve); + }); + await Promise.race([Promise.all(calls), deadline]); + if (timer !== undefined) clock.cancel(timer); + for (const module of running) { + logger.warn("Optional module hook ran out of time", { hook, module }); + } + }; + + // why: doctor reads findings at every run and at startup, so a module that keeps throwing is logged when it starts to, not on every read. + const unreadable = new Set(); + + return { + afterSocketClaimed(): void { + for (const { name, worker } of started) { + const call = worker.hooks?.afterSocketClaimed; + if (call !== undefined) void runHook(logger, name, "afterSocketClaimed", () => call()); + } + }, + beforeDrain: () => runStopStep("beforeDrain"), + beforeDriversDispose: () => runStopStep("beforeDriversDispose"), + capabilitySources, + findings() { + return started.flatMap(({ name, worker }) => { + try { + const found = worker.findings?.() ?? []; + unreadable.delete(name); + return found.map((finding) => ({ + kind: "module-unavailable" as const, + message: finding.message, + module: name, + remedy: finding.remedy, + })); + } catch (error: unknown) { + if (!unreadable.has(name)) { + unreadable.add(name); + logger.warn("Could not read optional module findings", { + message: messageOf(error), + module: name, + }); + } + return []; + } + }); + }, + leaseEndParticipants, + mounts(deps: MountDeps): readonly ModuleMount[] { + return started.flatMap(({ name, worker }) => + worker.mount === undefined ? [] : [{ handle: worker.mount(deps), prefix: name }], + ); + }, + relayTargets, + }; +} diff --git a/src/modules/index.ts b/src/modules/index.ts index 197830a5c..205c98891 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,149 +1,21 @@ -import type { z } from "zod"; - -import type { EventHandler, EventName } from "../bus/index.js"; -import type { OPERATIONS, OperationName, Role } from "../contract/index.js"; -import type { Config, Platform } from "../core/index.js"; -import type { - Clock, - Filesystem, - Logger, - ProcessRunner, - ProcessSupervisor, - TcpProbe, -} from "../ports/index.js"; - -export interface ModuleConfig { - readonly validator: (value: unknown, path: string, warn: (message: string) => void) => unknown; - readonly defaults: C; - readonly gatewayKeys: readonly string[]; - check?(section: C, config: Config): void; -} - -export interface ModuleFinding { - readonly message: string; - readonly remedy: string; -} - -export interface ModuleEndedLease { - readonly leaseId: string; - readonly ownerId: string; - readonly deviceId: string; - readonly platform: Platform; - readonly reason: "explicit" | "killed" | "expired" | "device-lost"; -} - -export interface ModuleLeaseEndParticipant { - leaseEnded(ended: ModuleEndedLease, signal: AbortSignal): Promise; -} - -export interface ModuleCapabilitySource { - readonly name: string; - readonly read: () => Promise<{ readonly state: string; readonly reason?: string }>; -} - -export type ModuleRelayTarget = ( - request: Request, - tenant: string, - leaseId: string | undefined, -) => Promise; - -export interface ModuleDispatchSession { - readonly principal: string; - readonly role: Role; - readonly manageEventSubscription: (subscribe: boolean) => string | undefined; -} - -export type ModuleDispatch = ( - operation: Op, - input: unknown, - session: ModuleDispatchSession, -) => Promise>; - -export interface ModuleIdentity { - readonly requesterId: string; - readonly role: "agent" | "operator" | "worker"; -} - -export type ModuleHandler = (request: Request, identity: ModuleIdentity) => Promise; - -export interface ModuleMount { - readonly prefix: string; - handle(request: Request, identity: ModuleIdentity): Promise; -} - -export interface MountDeps { - readonly dispatch: ModuleDispatch; -} - -export interface WorkerHooks { - readonly afterSocketClaimed?: () => void | Promise; - readonly beforeDeviceReclaim?: (lease: ModuleEndedLease, signal: AbortSignal) => Promise; - readonly beforeDrain?: () => Promise; - readonly beforeDriversDispose?: () => Promise; -} - -export interface WorkerModule { - readonly capability: ModuleCapabilitySource; - readonly relayTarget?: ModuleRelayTarget; - readonly mount?: (deps: MountDeps) => ModuleHandler; - readonly findings?: () => readonly ModuleFinding[]; - readonly hooks?: WorkerHooks; -} - -export interface WorkerContext { - readonly name: string; - readonly config: C; - readonly daemonConfig: Config; - readonly logger: Logger; - readonly clock: Clock; - readonly filesystem: Filesystem; - readonly processRunner: ProcessRunner; - readonly processSupervisor: ProcessSupervisor; - readonly tcpProbe: TcpProbe; - readonly dataDirectory: string; - readonly environment: NodeJS.ProcessEnv; - readonly nodePath: string; - readonly nodeVersion: string; - readonly platforms: readonly Platform[]; - readonly leaseEnvironment: Readonly>; - cancelPending(requesterId: string): Promise; - readonly events: { - subscribe(event: Event, handler: EventHandler): () => void; - }; -} - -export interface OptionalModuleDefinition { - readonly name: string; - readonly config: ModuleConfig; - loadWorker(): Promise<{ createWorker(ctx: WorkerContext): WorkerModule }>; - loadGateway?(): Promise; -} - -export interface ModuleHost { - readonly capabilitySources: readonly ModuleCapabilitySource[]; - readonly relayTargets: Readonly>; - readonly leaseEndParticipants: readonly ModuleLeaseEndParticipant[]; - mounts(deps: MountDeps): readonly ModuleMount[]; - findings(): readonly { - readonly kind: "module-unavailable"; - readonly module: string; - readonly message: string; - readonly remedy: string; - }[]; - afterSocketClaimed(): void; - beforeDrain(): Promise; - beforeDriversDispose(): Promise; -} - -export interface ModuleHostOptions { - readonly role: "worker"; - readonly definitions: readonly OptionalModuleDefinition[]; - readonly config: Config; - readonly logger: Logger; - readonly context: Omit, "name" | "config" | "daemonConfig" | "logger">; - readonly wiredElsewhere: readonly string[]; -} - -export function createModuleHost(_options: ModuleHostOptions): Promise { - return Promise.reject(new Error("createModuleHost is not built yet")); -} +export type { + ModuleCapabilitySource, + ModuleConfig, + ModuleDispatch, + ModuleDispatchSession, + ModuleEndedLease, + ModuleFinding, + ModuleHandler, + ModuleHost, + ModuleHostOptions, + ModuleIdentity, + ModuleLeaseEndParticipant, + ModuleMount, + ModuleRelayTarget, + MountDeps, + OptionalModuleDefinition, + WorkerContext, + WorkerHooks, + WorkerModule, +} from "./types.js"; +export { createModuleHost } from "./host.js"; diff --git a/src/modules/testing.ts b/src/modules/testing.ts index c413a8d3c..91edad7a7 100644 --- a/src/modules/testing.ts +++ b/src/modules/testing.ts @@ -67,7 +67,7 @@ export function fakeModule(options: FakeModuleOptions): FakeModule { return { capability: { name: options.capabilityName ?? options.name, - read: () => Promise.resolve({ state: options.state ?? "ready" }), + read: () => Promise.resolve({ state: options.state ?? "available" }), }, ...(options.findings === undefined ? {} : { findings: options.findings }), ...(options.hooks === undefined ? {} : { hooks: options.hooks }), diff --git a/src/modules/testing.ts-E b/src/modules/testing.ts-E deleted file mode 100644 index c9d51739e..000000000 --- a/src/modules/testing.ts-E +++ /dev/null @@ -1,131 +0,0 @@ -import { EventBus } from "../bus/index.js"; -import { loadConfig, type Config } from "../core/index.js"; -import { - FakeClock, - FakeProcessSupervisor, - FakeSystemStats, - FakeTcpProbe, - MemoryFilesystem, - NoopLogger, - ScriptedProcessRunner, -} from "../ports/index.js"; -import type { - ModuleFinding, - OptionalModuleDefinition, - WorkerContext, - WorkerHooks, - WorkerModule, -} from "./index.js"; - -export interface FakeModuleConfig { - readonly greeting: string; -} - -export interface FakeModuleOptions { - readonly name: string; - /** The capability name the created handle carries, when it should differ from `name`. */ - readonly capabilityName?: string; - readonly state?: string; - readonly hooks?: WorkerHooks; - readonly findings?: () => readonly ModuleFinding[]; - readonly relayTarget?: WorkerModule["relayTarget"]; - readonly mount?: WorkerModule["mount"]; - readonly loadError?: Error; - /** Runs inside `createWorker`, before the handle is returned. */ - readonly onCreate?: (ctx: WorkerContext) => void; -} - -export interface FakeModule { - readonly definition: OptionalModuleDefinition; - /** How many times the host asked to load it. */ - loads: number; - creates: number; - readonly contexts: WorkerContext[]; -} - -export function fakeModule(options: FakeModuleOptions): FakeModule { - const fake: FakeModule = { - contexts: [], - creates: 0, - definition: { - config: { - defaults: { greeting: "hello" }, - gatewayKeys: [], - validator: (value, path, warn) => { - const section = value as Record; - for (const key of Object.keys(section)) { - if (key !== "greeting") warn(`Unknown config key: "${path}.${key}"`); - } - return { greeting: section["greeting"] ?? "hello" }; - }, - }, - loadWorker: () => { - fake.loads += 1; - if (options.loadError !== undefined) return Promise.reject(options.loadError); - return Promise.resolve({ - createWorker: (ctx: WorkerContext): WorkerModule => { - fake.creates += 1; - fake.contexts.push(ctx); - options.onCreate?.(ctx); - return { - capability: { - name: options.capabilityName ?? options.name, - read: () => Promise.resolve({ state: options.state ?? "ready" }), - }, - ...(options.findings === undefined ? {} : { findings: options.findings }), - ...(options.hooks === undefined ? {} : { hooks: options.hooks }), - ...(options.mount === undefined ? {} : { mount: options.mount }), - ...(options.relayTarget === undefined ? {} : { relayTarget: options.relayTarget }), - }; - }, - }); - }, - name: options.name, - }, - loads: 0, - }; - return fake; -} - -export interface FakeWorkerContext { - readonly ctx: WorkerContext; - readonly clock: FakeClock; - readonly processRunner: ScriptedProcessRunner; - readonly processSupervisor: FakeProcessSupervisor; -} - -/** A context whose every port is a fake that records, so a test can see what a module started. */ -export async function fakeWorkerContext(name: string, config: C): Promise> { - const clock = new FakeClock(1_000); - const processRunner = new ScriptedProcessRunner([]); - const processSupervisor = new FakeProcessSupervisor(); - const daemonConfig: Config = await loadConfig({ - filesystem: new MemoryFilesystem(), - systemStats: new FakeSystemStats({ cpuCount: 4, totalRamBytes: 16 * 1024 ** 3 }), - }); - const bus = new EventBus(clock); - return { - clock, - ctx: { - cancelPending: () => Promise.resolve(undefined), - clock, - config, - daemonConfig, - dataDirectory: "/tmp/simlock-fake", - environment: {}, - events: { subscribe: (event, handler) => bus.subscribe(event, handler) }, - filesystem: new MemoryFilesystem(), - leaseEnvironment: {}, - logger: new NoopLogger(), - name, - nodePath: "/usr/bin/node", - nodeVersion: "22.0.0", - platforms: ["ios"], - processRunner, - processSupervisor, - tcpProbe: new FakeTcpProbe(), - }, - processRunner, - processSupervisor, - }; -} diff --git a/src/modules/types.ts b/src/modules/types.ts new file mode 100644 index 000000000..50a8cf2fd --- /dev/null +++ b/src/modules/types.ts @@ -0,0 +1,145 @@ +import type { z } from "zod"; + +import type { EventHandler, EventName } from "../bus/index.js"; +import type { OPERATIONS, OperationName, Role } from "../contract/index.js"; +import type { Config, Platform } from "../core/index.js"; +import type { + Clock, + Filesystem, + Logger, + ProcessRunner, + ProcessSupervisor, + TcpProbe, +} from "../ports/index.js"; + +export interface ModuleConfig { + readonly validator: (value: unknown, path: string, warn: (message: string) => void) => unknown; + readonly defaults: C; + readonly gatewayKeys: readonly string[]; + check?(section: C, config: Config): void; +} + +export interface ModuleFinding { + readonly message: string; + readonly remedy: string; +} + +export interface ModuleEndedLease { + readonly leaseId: string; + readonly ownerId: string; + readonly deviceId: string; + readonly platform: Platform; + readonly reason: "explicit" | "killed" | "expired" | "device-lost"; +} + +export interface ModuleLeaseEndParticipant { + leaseEnded(ended: ModuleEndedLease, signal: AbortSignal): Promise; +} + +export interface ModuleCapabilitySource { + readonly name: string; + readonly read: () => Promise<{ readonly state: string; readonly reason?: string }>; +} + +export type ModuleRelayTarget = ( + request: Request, + tenant: string, + leaseId: string | undefined, +) => Promise; + +export interface ModuleDispatchSession { + readonly principal: string; + readonly role: Role; + readonly manageEventSubscription: (subscribe: boolean) => string | undefined; +} + +export type ModuleDispatch = ( + operation: Op, + input: unknown, + session: ModuleDispatchSession, +) => Promise>; + +export interface ModuleIdentity { + readonly requesterId: string; + readonly role: "agent" | "operator" | "worker"; +} + +export type ModuleHandler = (request: Request, identity: ModuleIdentity) => Promise; + +export interface ModuleMount { + readonly prefix: string; + handle(request: Request, identity: ModuleIdentity): Promise; +} + +export interface MountDeps { + readonly dispatch: ModuleDispatch; +} + +export interface WorkerHooks { + readonly afterSocketClaimed?: () => void | Promise; + readonly beforeDeviceReclaim?: (lease: ModuleEndedLease, signal: AbortSignal) => Promise; + readonly beforeDrain?: () => Promise; + readonly beforeDriversDispose?: () => Promise; +} + +export interface WorkerModule { + readonly capability: ModuleCapabilitySource; + readonly relayTarget?: ModuleRelayTarget; + readonly mount?: (deps: MountDeps) => ModuleHandler; + readonly findings?: () => readonly ModuleFinding[]; + readonly hooks?: WorkerHooks; +} + +export interface WorkerContext { + readonly name: string; + readonly config: C; + readonly daemonConfig: Config; + readonly logger: Logger; + readonly clock: Clock; + readonly filesystem: Filesystem; + readonly processRunner: ProcessRunner; + readonly processSupervisor: ProcessSupervisor; + readonly tcpProbe: TcpProbe; + readonly dataDirectory: string; + readonly environment: NodeJS.ProcessEnv; + readonly nodePath: string; + readonly nodeVersion: string; + readonly platforms: readonly Platform[]; + readonly leaseEnvironment: Readonly>; + cancelPending(requesterId: string): Promise; + readonly events: { + subscribe(event: Event, handler: EventHandler): () => void; + }; +} + +export interface OptionalModuleDefinition { + readonly name: string; + readonly config: ModuleConfig; + loadWorker(): Promise<{ createWorker(ctx: WorkerContext): WorkerModule }>; + loadGateway?(): Promise; +} + +export interface ModuleHost { + readonly capabilitySources: readonly ModuleCapabilitySource[]; + readonly relayTargets: Readonly>; + readonly leaseEndParticipants: readonly ModuleLeaseEndParticipant[]; + mounts(deps: MountDeps): readonly ModuleMount[]; + findings(): readonly { + readonly kind: "module-unavailable"; + readonly module: string; + readonly message: string; + readonly remedy: string; + }[]; + afterSocketClaimed(): void; + beforeDrain(): Promise; + beforeDriversDispose(): Promise; +} + +export interface ModuleHostOptions { + readonly role: "worker"; + readonly definitions: readonly OptionalModuleDefinition[]; + readonly config: Config; + readonly logger: Logger; + readonly context: Omit, "name" | "config" | "daemonConfig" | "logger">; + readonly wiredElsewhere: readonly string[]; +} From feba55bfaaf0bf946b5e0e93eef4966b17cf8140 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 13:51:17 +0200 Subject: [PATCH 03/13] test: config.get fixtures carry capabilities (#595) why: the contract now requires capabilities. 4 failing -> 2 failing --- src/contract/operations.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index 062180752..91ba4512b 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -1071,6 +1071,7 @@ describe("operation input/output round trips", () => { it("config.get: round-trips a representative config", () => { const config = { + capabilities: {}, mode: "worker", gateway: { disconnectedRetentionMs: 86_400_000, From 223e0810800ca5719b5b632129a2c4e4f0551295 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 13:51:58 +0200 Subject: [PATCH 04/13] docs: capabilities, optional module routes, modules and module-registry manuals (#595) why: the change makes the config, HTTP and architecture docs incomplete. 2 failing -> 1 failing --- docs/CLIENT.md | 2 +- docs/CONFIGURATION.md | 3 +- docs/HTTP-API.md | 8 +++++ docs/internal/ARCHITECTURE.md | 5 +++ src/contract/operations.test.ts | 1 + src/core/AGENTS.md | 56 ++++++++++++++++----------------- src/module-registry/AGENTS.md | 6 ++++ src/modules/AGENTS.md | 22 +++++++++++++ 8 files changed, 73 insertions(+), 30 deletions(-) create mode 100644 src/module-registry/AGENTS.md create mode 100644 src/modules/AGENTS.md diff --git a/docs/CLIENT.md b/docs/CLIENT.md index fef7a0320..a1d08053e 100644 --- a/docs/CLIENT.md +++ b/docs/CLIENT.md @@ -15,7 +15,7 @@ import { connectSimlockAdmin } from "simlock/admin"; // agent + admin role `connectSimlockAdmin` returns a superset of `connectSimlock`'s client — every agent-role method plus the admin-role ones (`list`, `runCleanup`, `runNuke`, -`getConfig`, `stopDaemon`, `replayEvents`/`subscribeEvents` (each event carries an `id`), +`getConfig` (its `capabilities` block holds each optional module's section), `stopDaemon`, `replayEvents`/`subscribeEvents` (each event carries an `id`), `createToken`/`listTokens`/`revokeToken`, `installComponent`, `removeComponent`, `addDevice`, `removeDevice`). The split exists so `simlock/client` doesn't even show admin methods in a caller's editor; the diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index df5889247..4631c1497 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -32,6 +32,7 @@ edit yourself keeps the mode you give it until the next `config set`; run | `lease.maxRequestRecords` | The most lease-request records Simlock keeps. When a new request would go over it, the oldest settled record is removed. A request that is still open is never removed. | `10000` | | `lease.maxPerRequester` | How many leases one agent id may hold at once. A positive integer; anything else (`0`, `-1`, `1.5`, a string) stops the daemon from starting. An agent id still has at most one waiting request at a time. At the limit, or with a request already waiting, a new request is refused with `REQUESTER_ALREADY_LEASED`. On a gateway the limit counts the agent's leases across the whole fleet, and workers do not apply their own limit to what the gateway sends them. An agent that holds more than the limit (you lowered it and restarted) keeps its leases and cannot request more until it is below. Read at start; changing it needs a restart. **Risk above `1`:** agents that lease one device after another can block each other, each holding one device while waiting for a second.| `1` | | `lease.endWaitMs` | How long Simlock waits, after a lease ends, for add-ons that act on the device before it is wiped (for example one that ends a session on it). A positive number of milliseconds, at most `2147483647`; anything else stops the daemon from starting. When it passes, the wipe starts anyway. Stopping the daemon and `simlock nuke` do not wait. With no such add-on, nothing waits. Read at start; changing it needs a restart. | `600000` | +| `capabilities..enabled` | **Both sides.** Turns the optional module `` on. A module that is off is not loaded and is reported `off` in `status`. Each module's own keys sit beside `enabled` under `capabilities.`. A name no installed module has is warned about and ignored. A gateway always reads `enabled`; a module's other keys are warned about there unless the module lists them for gateways. This build has no optional module of its own, so `simlock config get` shows `"capabilities": {}`. | `false` for each module | | `gateway.url` | **Worker side.** Base URL of the gateway this worker joins, e.g. `wss://gw.example:4700`; the worker dials `/v1/uplink` and upgrades it to a WebSocket. Joining grants that gateway the `admin` role on this daemon, and `gateway.token` rides on the upgrade request as a bearer credential, so use `wss://` — or plain `ws://` only over loopback or inside your own tunnel. `http://` and `https://` are rejected at load. Unset means "do not join a fleet": the default, and the only thing that changes about a joined worker. | unset | | `gateway.token` | **Worker side.** The join token (`simlock token create --role worker`, minted on the gateway) this worker presents when it opens its uplink. Required whenever `gateway.url` is set. `simlock config` shows it; the daemon log and the `daemon.started` event show `[redacted]`. It is stored in plain text in `config.json`, which `simlock config set` writes owner-only. | unset | | `gateway.label` | **Worker side.** Display name for this worker in `simlock worker list`, `status`, the console, and on the lease's `worker` block. Display-only: nothing routes on it and it need not be unique. | the worker's own id | @@ -161,7 +162,7 @@ off by default. **A gateway reads a deliberately small slice of this file**: `mode`, `http.*`, `log.*`, `lease.*`, `eventBuffer.*`, `eventLog.*`, `gateway.*` and -`agentDevice.*` (`enabled` and `leaseTtlCapMs` only). Every other key — +`agentDevice.*` (`enabled` and `leaseTtlCapMs` only) and `capabilities.*` (`enabled`). Every other key — capacity, drivers, downloads, idle, warmPool, health, ios, android, stalledTransition — configures devices, which a gateway does not have; each one present in a gateway's config is reported with a warning and ignored, the same treatment an diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index d7ca2aae6..6ea44ac8c 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -1512,6 +1512,14 @@ from that worker's local agents, each with the **`workerId`** whose queue it is in. A request the gateway has sent to a worker is listed once, as the gateway's own. A worker's `createdAt` comes from that worker's clock. +### `/v1//*` + +An optional module turned on with `capabilities..enabled` and +`http.enabled` serves its own routes at `/v1/` and `/v1//*`, +behind the same agent token as every other route. A module cannot take the +name of a route above (`status`, `leases`, `workers` and the rest). This build +has no optional module of its own, so no such route exists yet. + ### `/v1/agent-device/*` A **worker** with `agentDevice.enabled` and `http.enabled` both `true` serves diff --git a/docs/internal/ARCHITECTURE.md b/docs/internal/ARCHITECTURE.md index c67975b52..eec082afc 100644 --- a/docs/internal/ARCHITECTURE.md +++ b/docs/internal/ARCHITECTURE.md @@ -35,6 +35,8 @@ worker daemon ── uplink (WebSocket to /v1/uplink, worker dials) ──┘ | Module | Does | Manual | |---|---|---| | contract | Every operation, schema, role, error code, protocol range | [contract](../../src/contract/AGENTS.md) | +| modules | Optional-module contract and the host that loads each turned-on module | [modules](../../src/modules/AGENTS.md) | +| module-registry | The list of optional modules, read by `main.ts` and the CLI | [module-registry](../../src/module-registry/AGENTS.md) | | daemon | Composition root, socket server, `Dispatcher`, sessions, uplink dialler | [daemon](../../src/daemon/AGENTS.md) | | leasing | Every lease rule: admit, queue, plan, grant, renew, end | [leasing](../../src/leasing/AGENTS.md) | | core | Registry, device lifecycle, reclaim, quarantine, cleanup, components, doctor | [core](../../src/core/AGENTS.md) | @@ -68,6 +70,9 @@ Arrows point from the optional to the required (architecture rule 15). | `core-not-leasing` | Core declares a port; the composition root connects leasing to it | | `core-not-drivers` | Core and leasing reach a platform only through the `Driver` interface | | `no-fs-or-process` | Core, leasing, agent-device and host-load use ports, never `fs` or `child_process` | +| `definition-is-light` | A module's `definition.ts` imports its own module only for types or by dynamic import | +| `modules-imports-little`, `modules-imports-bus-types-only` | `src/modules` imports only ports, contract and core, and the bus for types | +| `not-modules` | Core, leasing, drivers, gateway and contract import nothing from modules or module-registry | | `not-agent-device` | Core, leasing and drivers import nothing from agent-device | | `host-load-imports-ports-only`, `not-host-load` | host-load stands alone | | `gateway-not-edges`, `gateway-only-dispatch` | Gateway imports no driver or frontend, and from daemon only `src/daemon/dispatch.ts` | diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index 91ba4512b..20965b84f 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -1141,6 +1141,7 @@ describe("operation input/output round trips", () => { const warmPool = { targets, maxConcurrentBoots: 3 }; const output = OPERATIONS["config.get"].output; const base = { + capabilities: {}, mode: "worker", gateway: { disconnectedRetentionMs: 1, diff --git a/src/core/AGENTS.md b/src/core/AGENTS.md index 60a4400a6..c675c9934 100644 --- a/src/core/AGENTS.md +++ b/src/core/AGENTS.md @@ -9,34 +9,34 @@ See also: [leasing](../leasing/AGENTS.md) (lease flow, when reclaim runs), [warm ## Files -| File | Role | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------- | -| **Wiring and state** | | -| `create-core.ts`, `core-ports.ts` | `createCore`, `Core`, `CorePorts`; read ports (`CapacityReader`, `WarmPoolReader`, …) | -| `registry.ts` | `Registry`: the one writer of `state.json` | -| `domain.ts` | Records, specs, `transition`, `fits`, `mayBeGranted`, `sameSpec` | -| `serialized-decision.ts`, `device-operation-claims.ts` | The lock; one in-memory claim per device | -| `config.ts`, `validation.ts` | `loadConfig`, defaults, validators, `redactConfig`, `effectiveAllowDownload` | -| `lease-request-store.ts` | Pure lease-request record helpers, shared with leasing | -| **Device verbs** | | -| `managed-device-lifecycle.ts` | `ManagedDeviceLifecycle`: every driver verb on a registered device | -| `device-provisioner.ts` | `DeviceProvisioner`: the one place a device record is created | -| `reclaim-coordinator.ts` | `ReclaimCoordinator`: wipe after a lease, spent delete, interrupted reclaim | -| `quarantine-coordinator.ts` | `QuarantineCoordinator`: entry, retry on backoff, give up | -| `nuke-service.ts`, `nuke.ts` | `NukeService` (operator reset); `Nuke` (command facade) | -| `idle-order.ts` | LRU order and eviction victim choice | -| **Background** | | -| `warm-pool/`, `capacity/` | What stays warm; limits, RAM budget, reservations (own manuals) | -| `reaper.ts`, `cleanup/`, `cleanup-executor.ts` | `CleanupReaper`, pure rules, `CleanupExecutor` | -| **Startup, health, drivers** | | -| `startup-read.ts`, `startup-converger.ts` | `readStartup`; `StartupConverger`, the device half of startup | -| `doctor.ts`, `host-facts.ts` | `Doctor` findings and fixes; `HostFactsReader` for status | -| `driver.ts`, `driver-catalog.ts` | `Driver` interface, errors, `DiskSpaceGuard`; `DriverCatalog` | -| `catalog-match.ts` | Model name, class and paired runtimes against a catalog entry | -| `component-installer.ts` | `ComponentInstaller`: install, list, remove | -| `physical-enrollment.ts`, `physical-presence.ts` | `device add`/`remove`; every presence read, `latestPresence` | -| `device-root.ts`, `instance-identity.ts` | `ensureOwnedRoot`; `loadInstanceId` | -| `stable-error.ts`, `testing.ts`, `fake-driver.ts` | `stableError`; test-only wiring and `FakeDriver` | +| File | Role | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Wiring and state** | | +| `create-core.ts`, `core-ports.ts` | `createCore`, `Core`, `CorePorts`; read ports (`CapacityReader`, `WarmPoolReader`, …) | +| `registry.ts` | `Registry`: the one writer of `state.json` | +| `domain.ts` | Records, specs, `transition`, `fits`, `mayBeGranted`, `sameSpec` | +| `serialized-decision.ts`, `device-operation-claims.ts` | The lock; one in-memory claim per device | +| `config.ts`, `validation.ts` | `loadConfig` (with the optional modules' `ModuleConfigEntry` list: `capabilities.` sections, `enabled` owned here), defaults, validators, `redactConfig`, `effectiveAllowDownload` | +| `lease-request-store.ts` | Pure lease-request record helpers, shared with leasing | +| **Device verbs** | | +| `managed-device-lifecycle.ts` | `ManagedDeviceLifecycle`: every driver verb on a registered device | +| `device-provisioner.ts` | `DeviceProvisioner`: the one place a device record is created | +| `reclaim-coordinator.ts` | `ReclaimCoordinator`: wipe after a lease, spent delete, interrupted reclaim | +| `quarantine-coordinator.ts` | `QuarantineCoordinator`: entry, retry on backoff, give up | +| `nuke-service.ts`, `nuke.ts` | `NukeService` (operator reset); `Nuke` (command facade) | +| `idle-order.ts` | LRU order and eviction victim choice | +| **Background** | | +| `warm-pool/`, `capacity/` | What stays warm; limits, RAM budget, reservations (own manuals) | +| `reaper.ts`, `cleanup/`, `cleanup-executor.ts` | `CleanupReaper`, pure rules, `CleanupExecutor` | +| **Startup, health, drivers** | | +| `startup-read.ts`, `startup-converger.ts` | `readStartup`; `StartupConverger`, the device half of startup | +| `doctor.ts`, `host-facts.ts` | `Doctor` findings and fixes; `HostFactsReader` for status | +| `driver.ts`, `driver-catalog.ts` | `Driver` interface, errors, `DiskSpaceGuard`; `DriverCatalog` | +| `catalog-match.ts` | Model name, class and paired runtimes against a catalog entry | +| `component-installer.ts` | `ComponentInstaller`: install, list, remove | +| `physical-enrollment.ts`, `physical-presence.ts` | `device add`/`remove`; every presence read, `latestPresence` | +| `device-root.ts`, `instance-identity.ts` | `ensureOwnedRoot`; `loadInstanceId` | +| `stable-error.ts`, `testing.ts`, `fake-driver.ts` | `stableError`; test-only wiring and `FakeDriver` | ## Wiring diff --git a/src/module-registry/AGENTS.md b/src/module-registry/AGENTS.md new file mode 100644 index 000000000..c48f15540 --- /dev/null +++ b/src/module-registry/AGENTS.md @@ -0,0 +1,6 @@ +# Module registry + +Does: lists the optional modules (`optionalModules`), empty until a module is added. +Entry: `index.ts` → `optionalModules`. +Uses: `src/modules` types. A module's entry here is its `definition.ts`, which loads no module code (dependency rule `definition-is-light`). +Imported by: `daemon/main.ts` and `cli/index.ts`, for each module's config. diff --git a/src/modules/AGENTS.md b/src/modules/AGENTS.md new file mode 100644 index 000000000..bbe6c1f01 --- /dev/null +++ b/src/modules/AGENTS.md @@ -0,0 +1,22 @@ +# Modules + +Does: the contract an optional module implements, and the host that loads each turned-on module and plugs its handle into the daemon. +Entry: `index.ts` → `createModuleHost` and the contract types; `testing.ts` → `fakeModule`, `fakeWorkerContext`. +Uses: ports, contract, core; the bus for types only. Never imports: leasing, gateway, drivers, agent-device, `fs`. +Imported by: `daemon/main.ts` (the factory and types). Core, leasing, drivers, gateway and contract never import it. + +## Files + +| File | Role | +| ------------ | --------------------------------------------------------------------------------------------------------------- | +| `types.ts` | `OptionalModuleDefinition`, `ModuleConfig`, `WorkerModule`, `WorkerHooks`, `WorkerContext`, the by-shape copies | +| `host.ts` | `createModuleHost`: checks names, loads turned-on modules, runs hooks | +| `testing.ts` | Fake module and context for tests and the conformance suite | + +## Rules + +- A module is on only under `capabilities..enabled`; off, nothing of it is loaded and its capability reads `off`. +- Names: the capability name pattern, unique (also against `wiredElsewhere`), not a core route segment, at most 16 capabilities. +- Creating starts nothing. A start that fails before the socket is claimed drops created modules; after it, the normal stop runs their stop hooks. +- Hooks are direct calls. `afterSocketClaimed` is not awaited; `beforeDrain` and `beforeDriversDispose` share one 30 s budget per step; a throw is logged and the step goes on. +- Each by-shape type (`Module*`) has a type test in `src/daemon/module-shapes.test.ts` against its original. From 445b7f6310908c768f712988625eb46f6416a7a2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 13:52:10 +0200 Subject: [PATCH 05/13] docs: daemon manual names the module host (#595) why: main.ts now builds the host. --- src/daemon/AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index 03bb296a7..4a26c26df 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -2,7 +2,7 @@ Does: one process per data directory — composes a worker or a gateway and serves the contract on a unix socket. Entry: `main.ts` → `startDaemon(options)` → a started `DaemonServer`. -Uses: contract, daemon-protocol, ports, bus, link, core, leasing, drivers, http, host-load, agent-device; gateway from `main.ts` only. +Uses: contract, daemon-protocol, ports, bus, link, core, leasing, drivers, http, host-load, agent-device, modules, module-registry; gateway from `main.ts` only. Never imports: gateway outside `main.ts`. `dispatch.ts` imports no core, drivers or http. Imported by: http (`dispatcher.ts`, `error-code.ts`, `owner-routed-facts.ts`); gateway (`dispatch.ts` only). Started by: cli and mcp spawn `main.ts` as a detached process. @@ -82,7 +82,7 @@ One pipeline for `Dispatcher`, `GatewayDispatcher` and HTTP: ## Readiness `DaemonServer.start`: claim → host load start → `admin.token` write → converge -starts → `onSocketClaimed` (HTTP, agent-device, uplink) → await converge → arm. +starts → `onSocketClaimed` (HTTP, agent-device, module `afterSocketClaimed`, uplink) → await converge → arm. Health: `starting` → `running`, or `failed`. - `hello` and `status.get` answer at once; `status.get` while `starting` carries From 49a6760b66b3c0d5946d8eeac5a8d31274126ee1 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 13:52:25 +0200 Subject: [PATCH 06/13] fix: index exports only what other components import (#595) why: Fallow flags unused re-exports. --- src/modules/index.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/modules/index.ts b/src/modules/index.ts index 205c98891..eaa904ef7 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,18 +1,11 @@ export type { ModuleCapabilitySource, - ModuleConfig, ModuleDispatch, - ModuleDispatchSession, ModuleEndedLease, ModuleFinding, - ModuleHandler, - ModuleHost, - ModuleHostOptions, - ModuleIdentity, ModuleLeaseEndParticipant, ModuleMount, ModuleRelayTarget, - MountDeps, OptionalModuleDefinition, WorkerContext, WorkerHooks, From 1f497d8e6ae7797725dadedb2156450b72df70a7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 13:53:27 +0200 Subject: [PATCH 07/13] docs: manuals beside CLAUDE.md, no file names that do not exist (#595) why: the manuals test. 2 failing -> 1 failing (plus the busy-state spec test) --- docs/internal/ARCHITECTURE.md | 4 ++-- src/module-registry/AGENTS.md | 2 +- src/module-registry/CLAUDE.md | 1 + src/modules/CLAUDE.md | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 src/module-registry/CLAUDE.md create mode 100644 src/modules/CLAUDE.md diff --git a/docs/internal/ARCHITECTURE.md b/docs/internal/ARCHITECTURE.md index eec082afc..49beff7ed 100644 --- a/docs/internal/ARCHITECTURE.md +++ b/docs/internal/ARCHITECTURE.md @@ -36,7 +36,7 @@ worker daemon ── uplink (WebSocket to /v1/uplink, worker dials) ──┘ |---|---|---| | contract | Every operation, schema, role, error code, protocol range | [contract](../../src/contract/AGENTS.md) | | modules | Optional-module contract and the host that loads each turned-on module | [modules](../../src/modules/AGENTS.md) | -| module-registry | The list of optional modules, read by `main.ts` and the CLI | [module-registry](../../src/module-registry/AGENTS.md) | +| module-registry | The list of optional modules, read by the composition root and the CLI | [module-registry](../../src/module-registry/AGENTS.md) | | daemon | Composition root, socket server, `Dispatcher`, sessions, uplink dialler | [daemon](../../src/daemon/AGENTS.md) | | leasing | Every lease rule: admit, queue, plan, grant, renew, end | [leasing](../../src/leasing/AGENTS.md) | | core | Registry, device lifecycle, reclaim, quarantine, cleanup, components, doctor | [core](../../src/core/AGENTS.md) | @@ -70,7 +70,7 @@ Arrows point from the optional to the required (architecture rule 15). | `core-not-leasing` | Core declares a port; the composition root connects leasing to it | | `core-not-drivers` | Core and leasing reach a platform only through the `Driver` interface | | `no-fs-or-process` | Core, leasing, agent-device and host-load use ports, never `fs` or `child_process` | -| `definition-is-light` | A module's `definition.ts` imports its own module only for types or by dynamic import | +| `definition-is-light` | A module's definition file imports its own module only for types or by dynamic import | | `modules-imports-little`, `modules-imports-bus-types-only` | `src/modules` imports only ports, contract and core, and the bus for types | | `not-modules` | Core, leasing, drivers, gateway and contract import nothing from modules or module-registry | | `not-agent-device` | Core, leasing and drivers import nothing from agent-device | diff --git a/src/module-registry/AGENTS.md b/src/module-registry/AGENTS.md index c48f15540..660882f04 100644 --- a/src/module-registry/AGENTS.md +++ b/src/module-registry/AGENTS.md @@ -2,5 +2,5 @@ Does: lists the optional modules (`optionalModules`), empty until a module is added. Entry: `index.ts` → `optionalModules`. -Uses: `src/modules` types. A module's entry here is its `definition.ts`, which loads no module code (dependency rule `definition-is-light`). +Uses: `src/modules` types. A module's entry here is its definition file, which loads no module code (dependency rule `definition-is-light`). Imported by: `daemon/main.ts` and `cli/index.ts`, for each module's config. diff --git a/src/module-registry/CLAUDE.md b/src/module-registry/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/src/module-registry/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/modules/CLAUDE.md b/src/modules/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/src/modules/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 3b50cda6de7165f5c9292e33f05840a1444b4a76 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 13:54:57 +0200 Subject: [PATCH 08/13] test: capability fixture uses a state status.get accepts 1 failing -> 0 failing --- src/daemon/optional-modules.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/daemon/optional-modules.test.ts b/src/daemon/optional-modules.test.ts index 687616eca..750e7ab9a 100644 --- a/src/daemon/optional-modules.test.ts +++ b/src/daemon/optional-modules.test.ts @@ -181,10 +181,10 @@ describe("optional modules: turning on", () => { }); it("a module turned on is loaded once and reports the state its capability source answers", async () => { - const module = fakeModule({ name: "alpha", state: "busy" }); + const module = fakeModule({ name: "alpha", state: "available" }); const { daemon } = await start({ modules: definitions(module), on: ["alpha"] }); - expect(await capabilities(daemon)).toContainEqual({ name: "alpha", state: "busy" }); + expect(await capabilities(daemon)).toContainEqual({ name: "alpha", state: "available" }); expect({ loads: module.loads, creates: module.creates }).toEqual({ creates: 1, loads: 1 }); }); From 0acce261b5a2f7e1bbb5f144cf0436572545e9b4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 14:08:45 +0200 Subject: [PATCH 09/13] test: the context a module receives carries its section without enabled (#595) --- src/daemon/optional-modules.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/daemon/optional-modules.test.ts b/src/daemon/optional-modules.test.ts index 750e7ab9a..c6eb6f076 100644 --- a/src/daemon/optional-modules.test.ts +++ b/src/daemon/optional-modules.test.ts @@ -525,11 +525,13 @@ describe("optional modules: a start that cannot go on", () => { describe("optional modules: the context a module receives", () => { it("has a logger named after the module, and events.subscribe delivers a bus event", async () => { const delivered: string[] = []; + const seen: { config?: unknown } = {}; const { daemon, sink } = await start({ modules: definitions( fakeModule({ name: "alpha", onCreate: (ctx) => { + seen.config = ctx.config; ctx.logger.info("alpha says hello"); ctx.events.subscribe("lease.released", (envelope) => { delivered.push(envelope.payload.leaseId); @@ -546,6 +548,7 @@ describe("optional modules: the context a module receives", () => { "daemon.alpha", ]); expect(delivered).toEqual([leaseId]); + expect(seen.config).toEqual({ greeting: "hello" }); }); }); From bdb00540aaf9f69b46c82937b550b0fd050ed616 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 14:23:03 +0200 Subject: [PATCH 10/13] fix: index exports the module config types, stop-order and reclaim tests prove their claims, readiness order in the daemon manual (#595) --- src/daemon/AGENTS.md | 2 +- src/daemon/main.test.ts | 92 +++++++++++++++++++++++++ src/daemon/optional-modules.test.ts | 103 ++++++++++++++++++++++++++-- src/lint-rules.test.ts | 2 + src/modules/index.ts | 2 + 5 files changed, 193 insertions(+), 8 deletions(-) diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index 4a26c26df..3ffc9cc1c 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -82,7 +82,7 @@ One pipeline for `Dispatcher`, `GatewayDispatcher` and HTTP: ## Readiness `DaemonServer.start`: claim → host load start → `admin.token` write → converge -starts → `onSocketClaimed` (HTTP, agent-device, module `afterSocketClaimed`, uplink) → await converge → arm. +starts → `onSocketClaimed` (module `afterSocketClaimed`, agent-device, uplink, HTTP) → await converge → arm. Health: `starting` → `running`, or `failed`. - `hello` and `status.get` answer at once; `status.get` while `starting` carries diff --git a/src/daemon/main.test.ts b/src/daemon/main.test.ts index 083465c49..416391dd2 100644 --- a/src/daemon/main.test.ts +++ b/src/daemon/main.test.ts @@ -17,6 +17,7 @@ import { } from "../core/index.js"; import { FakeAgentDeviceHealth } from "../agent-device/testing.js"; import { FakeDriver } from "../core/testing.js"; +import { fakeModule, type FakeModule } from "../modules/testing.js"; import { IosSimctlDriver } from "../drivers/ios/index.js"; import { EventBus } from "../bus/index.js"; import { DAEMON_PROTOCOL_VERSION } from "../daemon-protocol/index.js"; @@ -45,6 +46,7 @@ import { type DriverDiscoveryContext, type StartDaemonOptions, } from "./main.js"; +import type { OptionalModuleDefinition } from "../modules/index.js"; import type { DaemonServer } from "./server.js"; const runningDaemons: DaemonServer[] = []; @@ -2420,6 +2422,7 @@ describe("startDaemon with the agent-device edge", () => { setup: Awaited>, ports: { readonly drivers?: StartDaemonOptions["drivers"]; + readonly modules?: readonly FakeModule[]; readonly processRunner: ScriptedProcessRunner; readonly processSupervisor: FakeProcessSupervisor; }, @@ -2436,6 +2439,13 @@ describe("startDaemon with the agent-device edge", () => { filesystem: setup.filesystem, hostInfo: new FakeHostInfo({ arch: "arm64", os: "macOS", osVersion: "15.5" }), logger: new JsonLinesLogger({ clock, level: "debug", sink }), + ...(ports.modules === undefined + ? {} + : { + optionalModules: ports.modules.map( + (module) => module.definition as OptionalModuleDefinition, + ), + }), processRunner: ports.processRunner, processSupervisor: ports.processSupervisor, statePath: join(setup.directory, "state.json"), @@ -2446,6 +2456,57 @@ describe("startDaemon with the agent-device edge", () => { return { daemon, sink }; } + it("runs a module's beforeDriversDispose after agent-device's child is stopped and before the drivers are disposed", async () => { + const setup = await setUp({ + agentDevice: { command, enabled: true }, + capabilities: { alpha: { enabled: true } }, + }); + const events: string[] = []; + const processSupervisor = new KillingSupervisor(); + const runner = versionAndDaemon(setup.directory); + const spawn = runner.spawn.bind(runner); + vi.spyOn(runner, "spawn").mockImplementation((spawned, args, options) => { + const handle = spawn(spawned, args, options); + if (args[0] === entry) { + processSupervisor.markAlive(handle.pid); + processSupervisor.kills.set(handle.pid, () => handle.kill()); + void handle.wait().then(() => events.push("agent-device exited")); + } + return handle; + }); + const clock = new FakeClock(1_000); + class DisposingDriver extends FakeDriver { + async dispose(): Promise { + events.push("driver disposed"); + } + } + const { daemon } = await startEdge(setup, { + drivers: [new DisposingDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" })], + modules: [ + fakeModule({ + hooks: { + beforeDriversDispose: () => { + events.push("module beforeDriversDispose"); + return Promise.resolve(); + }, + }, + name: "alpha", + }), + ], + processRunner: runner, + processSupervisor, + }); + await pollUntil(() => runner.calls.length === 2); + + await daemon.stop("test"); + + expect(events).toEqual([ + "agent-device exited", + "module beforeDriversDispose", + "driver disposed", + ]); + }); + it("resolves and grants a lease to an ordinary client while agent-device's /health never answers, and stops the child before the drivers are disposed", async () => { const setup = await setUp({ agentDevice: { command, enabled: true } }); const events: string[] = []; @@ -2627,6 +2688,37 @@ describe("startDaemon with the agent-device edge", () => { }); }, 10_000); + it("lists agent-device's module-unavailable finding before a module's own", async () => { + const setup = await setUp({ + agentDevice: { command: "/opt/missing/agent-device", enabled: true }, + capabilities: { alpha: { enabled: true } }, + }); + const { daemon } = await startEdge(setup, { + modules: [ + fakeModule({ + findings: () => [{ message: "it is down", remedy: "bring it up" }], + name: "alpha", + }), + ], + processRunner: new ScriptedProcessRunner([]), + processSupervisor: new FakeProcessSupervisor(), + }); + + await expect + .poll( + async () => + ( + (await daemon.dispatch("doctor.run", {}, operator)) as { + readonly findings: readonly { readonly kind: string; readonly module?: string }[]; + } + ).findings + .filter((finding) => finding.kind === "module-unavailable") + .map((finding) => finding.module), + { timeout: 5_000 }, + ) + .toEqual(["agent-device", "alpha"]); + }); + it("reports module-unavailable for agent-device, naming the command when agentDevice.command is found nowhere", async () => { const setup = await setUp({ agentDevice: { command: "/opt/missing/agent-device", enabled: true }, diff --git a/src/daemon/optional-modules.test.ts b/src/daemon/optional-modules.test.ts index c6eb6f076..924285ba9 100644 --- a/src/daemon/optional-modules.test.ts +++ b/src/daemon/optional-modules.test.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -85,6 +85,8 @@ interface RigOptions { readonly modules: readonly OptionalModuleDefinition[]; readonly on?: readonly string[]; readonly http?: boolean; + // why: agent-device's allocate names no model, so the driver must list a default phone for it. + readonly phones?: boolean; readonly extra?: Partial; readonly overrides?: Record; readonly notes?: string[]; @@ -100,7 +102,21 @@ async function prepare(options: RigOptions) { const clock = new FakeClock(1_000); const sink = new MemoryLogSink(); const notes = options.notes ?? []; - const driver = new NotingDriver({ availableOsVersions: ["26.5"], clock, platform: "ios" }, notes); + const driver = new NotingDriver( + { + availableOsVersions: ["26.5"], + clock, + platform: "ios", + ...(options.phones === true + ? { + defaultModels: { phone: ["iPhone 16"] }, + knownModels: ["iPhone 16"], + modelClasses: { "iPhone 16": "phone" as const }, + } + : {}), + }, + notes, + ); const port = options.http === true ? await freeLoopbackPort() : undefined; const startOptions = { clock, @@ -160,6 +176,8 @@ async function leaseAndRelease(daemon: DaemonServer): Promise { return grant.lease.id; } +// why: a value no default uses, so a timer with it is the lease-end wait ceiling a participant arms. +const END_WAIT_MS = 7_777; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); describe("optional modules: turning on", () => { @@ -181,10 +199,12 @@ describe("optional modules: turning on", () => { }); it("a module turned on is loaded once and reports the state its capability source answers", async () => { - const module = fakeModule({ name: "alpha", state: "available" }); + const module = fakeModule({ name: "alpha", state: "unavailable" }); const { daemon } = await start({ modules: definitions(module), on: ["alpha"] }); - expect(await capabilities(daemon)).toContainEqual({ name: "alpha", state: "available" }); + expect(await capabilities(daemon)).toContainEqual( + expect.objectContaining({ name: "alpha", state: "unavailable" }), + ); expect({ loads: module.loads, creates: module.creates }).toEqual({ creates: 1, loads: 1 }); }); @@ -412,7 +432,7 @@ describe("optional modules: a start that cannot go on", () => { { bad: () => Array.from({ length: 16 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), good: () => Array.from({ length: 15 }, (_, i) => fakeModule({ name: `mod-${String(i)}` })), - names: ["16"], + names: ["mod-15", "16"], title: "16 fake modules plus agent-device wired by hand (17 capabilities)", }, { @@ -653,21 +673,25 @@ describe("optional modules: afterSocketClaimed", () => { describe("optional modules: beforeDeviceReclaim", () => { it("a release reclaims the device when no turned-on module has beforeDeviceReclaim", async () => { const notes: string[] = []; - const { daemon } = await start({ + const { clock, daemon } = await start({ modules: definitions(fakeModule({ name: "alpha" })), notes, on: ["alpha"], + overrides: { lease: { endWaitMs: END_WAIT_MS } }, }); + const timers = vi.spyOn(clock, "setTimer"); + await leaseAndRelease(daemon); await vi.waitFor(() => expect(notes).toContain("driver reclaim")); + expect(timers.mock.calls.map(([delay]) => delay)).not.toContain(END_WAIT_MS); }); it("beforeDeviceReclaim runs after a release commits and before the device's reclaim starts", async () => { const notes: string[] = []; let live: DaemonServer | undefined; - const { daemon } = await start({ + const { clock, daemon } = await start({ modules: definitions( fakeModule({ hooks: { @@ -689,13 +713,16 @@ describe("optional modules: beforeDeviceReclaim", () => { ), notes, on: ["alpha"], + overrides: { lease: { endWaitMs: END_WAIT_MS } }, }); live = daemon; + const timers = vi.spyOn(clock, "setTimer"); await leaseAndRelease(daemon); await vi.waitFor(() => expect(notes).toContain("driver reclaim")); expect(notes).toEqual(["lease gone", "hook done", "driver reclaim"]); + expect(timers.mock.calls.map(([delay]) => delay)).toContain(END_WAIT_MS); }); }); @@ -737,6 +764,68 @@ describe("optional modules: stop hooks", () => { expect(messages.indexOf("drain done")).toBeLessThan(messages.indexOf("Daemon stopping")); }); + it("beforeDrain runs after agent-device's waiting allocates are cancelled through leasing", async () => { + const cancelledAtDrain: number[] = []; + let directory = ""; + const rig = await start({ + extra: { agentDeviceUpstream: new FakeAgentDevice() }, + http: true, + modules: definitions( + fakeModule({ + hooks: { + beforeDrain: async () => { + const history = await readFile(join(directory, "events.jsonl"), "utf8"); + cancelledAtDrain.push( + history + .split("\n") + .filter( + (line) => line.includes('"lease.rejected"') && line.includes('"cancelled"'), + ).length, + ); + }, + }, + name: "alpha", + }), + ), + on: ["alpha"], + phones: true, + overrides: { + agentDevice: { enabled: true }, + capacity: { config: { maxRunning: 1 }, strategy: "fixed" }, + }, + }); + directory = rig.directory; + const headers = await agentHeaders(rig.daemon); + await rig.daemon.dispatch("lease.request", ios, agent); + const allocate = fetch(`${rig.origin}/v1/agent-device/rpc`, { + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "agent_device.lease.allocate", + params: { + clientId: "client-1", + leaseId: "2".padStart(32, "0"), + leaseProvider: "proxy", + platform: "ios", + runId: "run-1", + ttlMs: 120_000, + }, + }), + headers: { ...headers, "content-type": "application/json" }, + method: "POST", + }).catch(() => undefined); + await vi.waitFor(async () => + expect( + ((await rig.daemon.dispatch("list.get", { kind: "requests" }, admin)) as unknown[]).length, + ).toBe(1), + ); + + await rig.daemon.stop("test"); + await allocate; + + expect(cancelledAtDrain).toEqual([1]); + }); + it("beforeDriversDispose finishes before any driver's dispose starts", async () => { const notes: string[] = []; const { daemon } = await start({ diff --git a/src/lint-rules.test.ts b/src/lint-rules.test.ts index a103fbb81..c976ba4e4 100644 --- a/src/lint-rules.test.ts +++ b/src/lint-rules.test.ts @@ -545,6 +545,7 @@ describe("a module's definition.ts, read by the registry without loading the mod "src/light/index.ts": "export {};\n", "src/module-registry/index.ts": 'import "../heavy/definition.js";\nimport "../light/definition.js";\nexport {};\n', + "src/module-registry/other.ts": 'import "../light/impl.js";\nexport {};\n', }; function worldViolations(): Violation[] { @@ -571,6 +572,7 @@ describe("a module's definition.ts, read by the registry without loading the mod const found = worldViolations(); expect(found.filter((v) => v.from === "src/module-registry/index.ts")).toEqual([]); + expect(found.filter((v) => v.from === "src/module-registry/other.ts")).not.toEqual([]); }); }); diff --git a/src/modules/index.ts b/src/modules/index.ts index eaa904ef7..69aea6908 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,4 +1,5 @@ export type { + ModuleConfig, ModuleCapabilitySource, ModuleDispatch, ModuleEndedLease, @@ -6,6 +7,7 @@ export type { ModuleLeaseEndParticipant, ModuleMount, ModuleRelayTarget, + MountDeps, OptionalModuleDefinition, WorkerContext, WorkerHooks, From 804ebc22ae33f46ab071a8b6005f4feaacb8443b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 14:23:53 +0200 Subject: [PATCH 11/13] fix: the module config types the index exports are marked as the contract (#595) --- src/modules/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/modules/index.ts b/src/modules/index.ts index 69aea6908..34e60edfa 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,5 +1,8 @@ +// fallow-ignore-next-line unused-type -- the shape of a definition's `config`, which a module's `definition.ts` names. +export type { ModuleConfig } from "./types.js"; +// fallow-ignore-next-line unused-type -- the dependencies a module's `mount` receives, which a module's worker names. +export type { MountDeps } from "./types.js"; export type { - ModuleConfig, ModuleCapabilitySource, ModuleDispatch, ModuleEndedLease, @@ -7,7 +10,6 @@ export type { ModuleLeaseEndParticipant, ModuleMount, ModuleRelayTarget, - MountDeps, OptionalModuleDefinition, WorkerContext, WorkerHooks, From 4d3b3f216862d840bdf4bc916c0dbaaa4053e52a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 14:24:30 +0200 Subject: [PATCH 12/13] docs: the index's unused-type notes say no module is registered yet (#595) --- src/modules/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/index.ts b/src/modules/index.ts index 34e60edfa..21ab28820 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,6 +1,6 @@ -// fallow-ignore-next-line unused-type -- the shape of a definition's `config`, which a module's `definition.ts` names. +// fallow-ignore-next-line unused-type -- part of the contract a module imports from this index; no module is registered yet. export type { ModuleConfig } from "./types.js"; -// fallow-ignore-next-line unused-type -- the dependencies a module's `mount` receives, which a module's worker names. +// fallow-ignore-next-line unused-type -- part of the contract a module imports from this index; no module is registered yet. export type { MountDeps } from "./types.js"; export type { ModuleCapabilitySource, From 927c1fad23b54ee9a02adda4dadc75018303bfb9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 14:31:10 +0200 Subject: [PATCH 13/13] test(modules): a module that throws, recovers and throws again is warned again --- src/daemon/optional-modules.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/daemon/optional-modules.test.ts b/src/daemon/optional-modules.test.ts index 924285ba9..bce0c529d 100644 --- a/src/daemon/optional-modules.test.ts +++ b/src/daemon/optional-modules.test.ts @@ -395,6 +395,29 @@ describe("optional modules: doctor findings", () => { recordsOf(sink, "Could not read optional module findings").map((record) => record.fields), ).toEqual([{ message: "boom", module: "bravo" }]); }); + + it("a module whose findings() throws, reads, then throws again is warned once per run of failures, not once per read", async () => { + const steps = ["throw", "throw", "read", "throw", "throw"]; + const { daemon, sink } = await start({ + modules: definitions( + fakeModule({ + findings: () => { + if (steps.shift() === "throw") throw new Error("boom"); + return []; + }, + name: "alpha", + }), + ), + on: ["alpha"], + }); + const startupWarnings = recordsOf(sink, "Could not read optional module findings").length; + + for (let run = 0; run < 5 - startupWarnings; run += 1) { + await daemon.dispatch("doctor.run", {}, admin); + } + + expect(recordsOf(sink, "Could not read optional module findings")).toHaveLength(2); + }); }); describe("optional modules: a start that cannot go on", () => {