Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion .dependency-cruiser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)$`,
},
}));

Expand Down Expand Up @@ -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/",
Expand Down
2 changes: 1 addition & 1 deletion docs/CLIENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.enabled` | **Both sides.** Turns the optional module `<name>` 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.<name>`. 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 `<url>/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 |
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/HTTP-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<module>/*`

An optional module turned on with `capabilities.<module>.enabled` and
`http.enabled` serves its own routes at `/v1/<module>` and `/v1/<module>/*`,
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
Expand Down
5 changes: 5 additions & 0 deletions docs/internal/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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) |
Expand Down Expand Up @@ -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 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 |
| `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` |
Expand Down
1 change: 1 addition & 0 deletions src/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
6 changes: 5 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion src/contract/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/contract/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1140,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,
Expand Down
7 changes: 7 additions & 0 deletions src/contract/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
helloRequestSchema,
negotiateProtocolVersion,
normalizeProtocolVersion,
PROTOCOL_VERSION_RANGE,
} from "./protocol.js";

describe("negotiateProtocolVersion", () => {
Expand Down Expand Up @@ -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 });
});
});
17 changes: 16 additions & 1 deletion src/contract/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -77,3 +77,18 @@ 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[] = [
"catalog",
"components",
"devices",
"events",
"healthz",
"lease-requests",
"leases",
"status",
"tokens",
"uplink",
"workers",
];
9 changes: 8 additions & 1 deletion src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading