From 76edc44d3d74d397d5183a3dc1a88f7d968c0fe8 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 14 Sep 2026 09:35:25 +0100 Subject: [PATCH] docs: describe fleet.yaml --- docs/README.md | 4 +- docs/commands/alias.md | 2 +- docs/commands/code.md | 2 +- docs/commands/fleet.md | 320 ++-------------------- docs/commands/gateway.md | 12 +- docs/commands/harness.md | 6 +- docs/commands/orchestrator.md | 8 +- docs/commands/serve.md | 2 +- docs/env-vars.md | 6 +- docs/fleet-file.md | 416 +++++++++++++++++++++++++++++ docs/spinloop-file.md | 2 +- docs/work-items.md | 8 +- examples/fleet-mixed/README.md | 5 +- examples/fleet-remote/README.md | 5 +- examples/gateway-docker/README.md | 2 +- examples/remote-spinloop/README.md | 2 +- 16 files changed, 468 insertions(+), 334 deletions(-) create mode 100644 docs/fleet-file.md diff --git a/docs/README.md b/docs/README.md index 3f3706dc..215f307f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,8 @@ Four words carry the whole tool: - [Getting started](getting-started.md) — the end-to-end flow - [The `Spinloop` file](spinloop-file.md) — syntax and examples +- [The `fleet.yaml` file](fleet-file.md) — the file a fleet is built from: + its nodes, its token references, and what it refuses - [Running on a cloud GPU](commands/remote.md) — the same Spinloop, on a machine that stops when you do - [The HTTP control API](http-api.md) — driving a supervised engine over @@ -84,7 +86,7 @@ including the `SPINLOOP_REMOTE_*` overrides: | `SPINLOOP_BASE_URL` | Overrides any provider's API base URL (`--base-url`/`-u` beats it) | | `SPINLOOP_API_TOKEN` | Bearer token for the daemon [control API](http-api.md) | | `SPINLOOP_LOG_LEVEL` | How much `spinloop daemon`/`spinloop serve` record — `debug`, `info` (default), `warn`, `error` (`--log-level` beats it) | -| *(named by `tokenEnv`)* | A [fleet](commands/fleet.md) node's bearer token — `fleet.yaml` names the variable, never the value | +| *(named by `tokenEnv`)* | A fleet node's bearer token — the [fleet file](fleet-file.md#tokens) names the variable, never the value | | `DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, … | Provider API keys — `spinloop provider list` shows which each provider reads | | `OLLAMA_BASE_URL`, `LLAMACPP_BASE_URL`, `OMLX_BASE_URL`, `VLLM_BASE_URL`, `MTPLX_BASE_URL`, `OPENAI_BASE_URL` | Per-provider endpoint overrides | | `AWS_REGION` | Region for AWS Bedrock | diff --git a/docs/commands/alias.md b/docs/commands/alias.md index 03ace0e6..64280377 100644 --- a/docs/commands/alias.md +++ b/docs/commands/alias.md @@ -30,7 +30,7 @@ spinloop harness apply team-default ``` It is stored and resolved just like a local one — see -[Fetching a Spinloop from a URL](../spinloop-file.md#fetching-an-spinloop-from-a-url). +[Fetching a Spinloop from a URL](../spinloop-file.md#fetching-a-spinloop-from-a-url). ## Naming one for the whole shell diff --git a/docs/commands/code.md b/docs/commands/code.md index 889230aa..14fb6998 100644 --- a/docs/commands/code.md +++ b/docs/commands/code.md @@ -14,7 +14,7 @@ spinloop code --fleet ./fleet.yaml # route through a fleet, or its gateway `code` is the only way to launch an agent against a fleet: `spinloop fleet harness` was removed, and typing it names this command instead. A fleet file -that names a [gateway](fleet.md#gateway) needs no Spinloop at all — the gateway +that names a [gateway](../fleet-file.md#gateway) needs no Spinloop at all — the gateway resolves the model per request — so `spinloop code --fleet ./fleet.yaml` is a complete launch on a machine holding nothing but the fleet file. diff --git a/docs/commands/fleet.md b/docs/commands/fleet.md index c671823c..50b6132c 100644 --- a/docs/commands/fleet.md +++ b/docs/commands/fleet.md @@ -86,297 +86,13 @@ nodes: ``` The file is found the way a `Spinloop` is: `./fleet.yaml` in the working -directory, or `--fleet `. - -### Where a node's engine answers - -A node's `host` and `port` name its **daemon**, which is a different port from -the **engine** it supervises. For [routing](#which-node-would-i-get) spinloop -needs the engine's, and the daemon reports it — so most nodes need nothing -more. Declare an `engine` block for the cases a daemon cannot describe: - -```yaml -nodes: - - name: containerised - host: docker-host - engine: - port: 18080 # published port, not the one it binds inside - - - name: proxied - host: node.local - engine: - host: https://engine.example # a reverse proxy in front of the engine - path: /openai # when it is not the usual /v1 -``` - -Each field falls back independently to what spinloop would otherwise derive: the -node's own `host`, and the port and path the daemon reports. - -An engine bound to loopback answers only on its own machine. Routing to it from -elsewhere fails with that explanation rather than a bare connection refused — -bind the engine to a reachable address (llama.cpp's `--host 0.0.0.0`), or -declare an `engine` block, which is you taking responsibility for reachability. - -### Remote environments - -`kind` (defaulted to `daemon`) says how the fleet reaches a node. A node can -also be an [`spinloop remote`](remote.md) environment rather than a machine: its -`name` is the registered environment it drives — no `host` needed — and it is -reached through its control plane, which signs each call with your AWS -credentials, so it needs no bearer token: - -```yaml -nodes: - - name: qwen # the registered environment, and what you type at `fleet start ` - kind: remote -``` - -The environment's control URLs live in its `remote.json` (under -`~/.config/spinloop/remotes//`), written by `spinloop remote deploy` — or by -[`spinloop fleet deploy`](#deploying-remote-nodes), which creates it from the -fleet file itself — and never stored in the fleet file. So a daemon and an -environment sit side by side as the same kind of row, and an environment that -has not been deployed yet shows as `config-error` on its row rather than -blanking the fleet. See -[`examples/fleet-remote`](../../examples/fleet-remote/README.md) and -[`examples/fleet-mixed`](../../examples/fleet-mixed/README.md). - -A `kind: remote` node may also name the EC2 instance type its environment -launches as, with `instance-type` (a family and size separated by a dot, e.g. -`g6e.xlarge`): - -```yaml -nodes: - - name: qwen - kind: remote - instance-type: g6e.2xlarge -``` - -It is a property of the cloud environment, not of the fleet's view of it: -`fleet deploy` records it on the environment, and the environment's next -**fresh** launch uses it. A re-wake of a stopped instance keeps the type it -was launched with — EC2 cannot resize a running or stopped box — so a changed -value takes effect only after the instance is terminated (an idle sweep or -`spinloop remote stop`) and launched again. Omitted, the environment launches -as its control plane's default type. Naming `instance-type` on a `kind: daemon` -node is a configuration error: a daemon's hardware is the operator's to choose, -not the fleet file's. - -### A node's Spinloop source - -Both `fleet deploy` (for a `kind: remote` node's environment) and `fleet -start` (for a `kind: daemon` node's engine) need to know what Spinloop file -describes what a node runs. A node names it with `file`, resolved relative to -the fleet file: - -```yaml -nodes: - - name: qwen - kind: remote - file: ./envs/qwen.Spinloop -``` - -`file` is optional, because the node's own `name` already doubles as a lookup -key. When it is absent, resolution tries, in order: - -1. `name` registered as a `spinloop alias` (`spinloop alias add qwen - ./envs/qwen.Spinloop`) — the same lookup `spinloop remote deploy` - performs for a Spinloop argument; -2. a subdirectory named after the node, beside the fleet file — `qwen/Spinloop` - next to `fleet.yaml` for a node named `qwen`, no fields needed on either - side. - -A fleet laid out as one subdirectory per node therefore needs nothing beyond -each node's own `name`: - -``` -fleet.yaml -qwen/Spinloop -llama/Spinloop -``` - -Nothing resolving is a per-node error naming all three ways a source could -have been given. For `fleet deploy` that always fails the node (there is -nothing to create an environment from); for `fleet start` on a `kind: daemon` -node it likewise fails that node's start — there is no fallback to a plain, -config-less start once this field exists. A `kind: remote` node's `start` is -unaffected by any of this: what it serves is fixed at deploy time, not pushed -at start time. - -This does not apply to `spinloop fleet dashboard`'s `s` key, which still -starts the selected node with a plain start, whatever the CLI's `fleet start` -would resolve for it. - -### Spreading or consolidating - -`prefer` decides which node wins when several could all serve you: - -```yaml -prefer: idle # or: active -nodes: … -``` - -- **`idle`** (the default) — the machine quiet longest wins. A node that is - mid-request is the *least* idle of all, so it is the last one chosen. Use it - when several people share the fleet, or you run several agents at once. -- **`active`** — the most recently active wins, consolidating sessions onto one - engine and leaving the others free to be woken for another model, or left - asleep. - -`spinloop harness open --prefer ` and `spinloop fleet route --prefer ` -override the file for one command, which is the cheap way to see what the other -setting would do before committing to it. - -### Waking - -`wake` decides whether routing may start an engine on a node that is not -running one: - -```yaml -wake: off # or: on -nodes: … -``` - -- **`on`** (the default, and the behaviour of a file that declares nothing) — - when nothing is serving, spinloop starts a node and waits for its engine to - answer before the agent launches or the request is answered. -- **`off`** — a request nothing is serving fails rather than starting - anything, naming the node that would have been woken and the `spinloop fleet - start ` command that would start it. Use it where the machines are not - to be started on demand — the models are loaded by hand, or someone else - drives the starts. - -An explicit `--no-wake` still refuses to start anything, whatever the file -says; an explicit `spinloop fleet start` does the opposite — it always starts, -because it was asked. - -### Tags - -A node's `tags` name the kind of work the node takes on — key/value pairs the -operator chooses: - -```yaml -nodes: - - name: gpu-box - host: 198.51.100.7 - tags: - gpu: a100 - os: linux -``` - -They are how an [`spinloop orchestrator`](orchestrator.md) item chooses its -node: an item names the tags of the nodes it may run on, and it matches a node -only where every one it names is a tag the node carries. A node with no tags -takes only items that name none. Routing, waking and the dashboard do not read -them — tags belong to the orchestrator's matching alone. - -### Concurrency - -`concurrency` is the pace this fleet works at, for the -[`spinloop orchestrator`](orchestrator.md): how much work it may take at once. -The limits are a ceiling the operator sets, not a measurement of the engines' -load: - -```yaml -concurrency: - total: 8 # the most items the fleet may have in flight at once - tags: - "gpu=a100": 4 # and, per tag, the most in flight on nodes carrying it -``` - -An admitted item counts against `total` and against the limit of every tag it -names, and it frees its counts when it ends. A limit on a tag no node carries -is a configuration error naming the tag, as is a limit that is not a positive -integer. A file that declares no `concurrency` has no limit: the orchestrator -admits as fast as the nodes match. - -### Gateway - -`gateway` names the address this fleet is served under by a -[`spinloop gateway`](gateway.md): a launch routed through this file is pointed -at the gateway rather than at a node. The gateway has done the choosing, so -the launch queries no node and wakes none: - -```yaml -nodes: … -gateway: - url: http://gateway.internal:4000 # required, with a scheme - tokenEnv: GATEWAY_TOKEN # optional; OPENAI_API_KEY when absent - name: remote-llms # optional; labels the gateway (see below) -``` - -A launch through such a file is pointed at the section's address — the agent's -base URL, with the OpenAI-compatible `/v1` prefix added when it carries no path -— and the token is resolved from the variable the section names — -`OPENAI_API_KEY` when it names none — the way a key is resolved elsewhere: an -`ENV` instruction, then the process environment, then the `.env` beside the -Spinloop. A variable set nowhere fails the launch before anything is written, -naming the variable. -As with a node's choice, the launch reports the address on stderr before the -agent starts. - -This is how a machine that holds the fleet file points a harness at the fleet: -A launch reads the section when it is there, so a Spinloop -beside the file needs only the model, and the address travels with the file. -`spinloop fleet route` answers a file that names a gateway the same way — the -address, and that no node is queried and nothing is started. - -When a launch through the gateway has no model of its own to route by (see -[Launching the harness](#launching-the-harness)), it needs a way to label the -provider it configures — otherwise every gateway a fleet might name would -collide under the same generic id. `name` supplies that label directly; with -none given, the section's address's host stands in (e.g. `localhost:4000`). -Either way opencode and Pi show it the way a remote environment is shown — -`Gateway (remote-llms)` rather than a bare `OpenAI-compatible`, the same -pattern as `llama.cpp (dev-2)`. - -### Tokens - -`tokenEnv` names an environment variable; the value is resolved from the -process environment first, then a `.env` beside the `fleet.yaml` — the same -precedence spinloop uses everywhere, so an exported value wins and the `.env` -only fills a gap. Put the secrets there: - -```sh -# .env beside fleet.yaml (gitignored) -GPU_BOX_TOKEN=… -``` - -A node with no `tokenEnv` is contacted without authentication, which is -correct for a daemon bound to loopback. Any node reachable over the network -needs a token — the daemon refuses to listen on a non-loopback address without -one. - -A `tokenEnv` naming a variable that is set nowhere is reported against that -node as `config-error`, so a typo shows up on its row rather than as a -mysterious `unauthorized`. - -A node whose **engine** needs a key names that separately, with -`engineTokenEnv`. The two are different credentials — one authorises driving the -node, the other authorises using its engine — and a node may need either, both, -or neither. It is resolved exactly as `tokenEnv` is, and the daemon never hands -its engine's key out: it says only that one is required. - -```yaml - - name: gated - host: gated.local - tokenEnv: GATED_TOKEN # to drive the daemon - engineTokenEnv: GATED_ENGINE_KEY # to talk to its engine -``` - -A `kind: remote` environment is always keyed, so it needs an engine key too — -its `engineTokenEnv` works as above, and a fleet-wide `apiKeyEnv` is the -default for every remote node that does not name one of its own. Either way the -launch fails before it starts the agent rather than pointing it at a gate it -cannot pass: - -```yaml -apiKeyEnv: REMOTE_ENGINE_KEY # the default for every kind: remote node -nodes: - - name: qwen - kind: remote - engineTokenEnv: OTHER_KEY # overrides it for this node -``` +directory, or `--fleet `. The full format reference — every field, a +node's [Spinloop source](../fleet-file.md#a-nodes-spinloop-source), +[remote environments](../fleet-file.md#remote-environments), +[`prefer`](../fleet-file.md#spreading-or-consolidating) and +[`wake`](../fleet-file.md#waking), [tags](../fleet-file.md#tags) and +[concurrency](../fleet-file.md#concurrency), the [gateway section](../fleet-file.md#gateway), +and [tokens](../fleet-file.md#tokens) — is [the `fleet.yaml` file](../fleet-file.md). ## A node that is down never blanks the view @@ -478,7 +194,7 @@ spinloop fleet dashboard --fleet f.yaml # another fleet file | `q` or `Ctrl+C` | Leave | The board keeps its own cadence: local machines are read every two seconds, -and a [`kind: remote`](#remote-environments) environment every 60 — one +and a [`kind: remote`](../fleet-file.md#remote-environments) environment every 60 — one status call a minute, because its status is a signed control-plane call, not a local socket, and a cold instance changes state on the scale of minutes. `r` is due for every node whatever those deadlines say. @@ -620,7 +336,7 @@ Would use gpu-box at http://gpu-box:8080/v1 serving qwen3-27b, active 312s ago (prefer idle) ``` -A file that names a [gateway](#gateway) is answered the way a launch answers +A file that names a [gateway](../fleet-file.md#gateway) is answered the way a launch answers it — the gateway's address, and that no node is queried and nothing is started. @@ -653,7 +369,7 @@ spinloop code -O=./client/Spinloop --fleet fleet.yaml spinloop code --fleet fleet.yaml --node gpu-box # the launch's steering flags ``` -A fleet file that names a [gateway](#gateway) points the agent there, so the +A fleet file that names a [gateway](../fleet-file.md#gateway) points the agent there, so the address lives in the file rather than in every Spinloop. The fleet comes from `--fleet`/`-f`, or from the `fleet.yaml` in the working @@ -664,20 +380,16 @@ picks it up. Routing is the launch's routing: at the gateway where the file names one, otherwise by node selection and, where the file's -[wake policy](#waking) allows, a wake — `--node`, `--prefer`, `--no-wake` and +[wake policy](../fleet-file.md#waking) allows, a wake — `--node`, `--prefer`, `--no-wake` and `--wake-timeout` steer it. A Spinloop that pins a `BASEURL` is not routed, and a variable already set in spinloop's environment wins. - -With no Spinloop to route, what happens next depends on the fleet file. -Routing to a node needs a model to match one against, so the launch fails -saying `--fleet` needs a Spinloop. A gateway needs no such match — it resolves the model per request — so a launch through one needs no Spinloop at all: the harness is configured with a generic OpenAI-compatible provider at the gateway's address, its model list populated from the gateway's own `GET /v1/models`, and no default model — labelled and keyed by the gateway's -[`name`](#gateway), or its address when the section names none, so a second +[`name`](../fleet-file.md#gateway), or its address when the section names none, so a second gateway gets its own block rather than overwriting this one (see -[Gateway](#gateway)). This applies equally to a Spinloop that is given but +[Gateway](../fleet-file.md#gateway)). This applies equally to a Spinloop that is given but names neither a `MODEL` nor an `ALIAS`. The populated model list is only as fresh as the last run of the command — rerun it to pick up a newly-served model — and, since not every harness's config format holds more than one @@ -710,7 +422,7 @@ running reports its conflict, and stopping one that is not running succeeds quietly. **Starting a `kind: daemon` node now requires its [Spinloop -source](#a-nodes-spinloop-source) to resolve.** When it does, `fleet start` +source](../fleet-file.md#a-nodes-spinloop-source) to resolve.** When it does, `fleet start` derives a deploy config from it and pushes it with the start (`StartWith`) — telling the daemon what to run, the same way a routed `harness` launch already tells a node what to run when it wakes one. When it does not resolve, @@ -734,7 +446,7 @@ spinloop fleet deploy --all # every kind: remote node in the file ``` Each node deploys from its own resolved [Spinloop -source](#a-nodes-spinloop-source), reusing the exact derivation, consent, and +source](../fleet-file.md#a-nodes-spinloop-source), reusing the exact derivation, consent, and registration `spinloop remote deploy` uses for the same file — the two can never disagree about what a given Spinloop deploys — and the environment each node creates is named after the node itself. A `kind: daemon` node @@ -776,6 +488,8 @@ deploy`](remote.md), applied per node. ## See also +- [The `fleet.yaml` file](../fleet-file.md) — the format reference for the + file this command reads - [`spinloop up`](up.md) — the one-word start, from a fleet directory - [`examples/fleet-local/`](../../examples/fleet-local/) — a fleet of one, on your own machine - [`examples/fleet-docker/`](../../examples/fleet-docker/) — a runnable fleet diff --git a/docs/commands/gateway.md b/docs/commands/gateway.md index bc5407ef..2f01990e 100644 --- a/docs/commands/gateway.md +++ b/docs/commands/gateway.md @@ -32,7 +32,7 @@ side. ## Pointing an agent at it -A [`gateway` section](fleet.md#gateway) in the fleet file names the address and +A [`gateway` section](../fleet-file.md#gateway) in the fleet file names the address and the variable holding the token. A launch routed through that file — `spinloop harness open -f` or `spinloop code -f` — is pointed at the gateway rather than a node: the section's address is the agent's base URL (with the OpenAI- @@ -93,7 +93,7 @@ once in a short window, so a poll of the models list is cheap. A completion request is answered by the fleet's own selection: a node already running the model wins, ranked by the fleet file's `prefer` with fleet-file order breaking ties. A node whose engine is bound to loopback without an -[`engine` override](fleet.md#where-a-nodes-engine-answers) is never selected, +[`engine` override](../fleet-file.md#where-a-nodes-engine-answers) is never selected, and when it is the only match the failure says so rather than holding the request until the wake timeout. @@ -111,12 +111,12 @@ the node. `GET /v1/fleet` answers with the fleet as it is now: the same cached fan-out the model listing reads, joined with the file's claims about each node and its fleet-level settings. Each node's entry carries its name, kind, -[tags](fleet.md#tags), state, what it serves (the served name where a running +[tags](../fleet-file.md#tags), state, what it serves (the served name where a running engine reports one, else the model id), whether it has answered its own health check, when it last did work — and, for a node that is not running, the model a request would start it with, where its own source describes one and the -file's [wake policy](fleet.md#waking) allows it. The file's `wake` and -`prefer` settings and its [concurrency](fleet.md#concurrency) limits ride +file's [wake policy](../fleet-file.md#waking) allows it. The file's `wake` and +`prefer` settings and its [concurrency](../fleet-file.md#concurrency) limits ride along, each absent where the file declares none. A node that does not answer is reported in its place — the way the fleet's own views report it — rather than failing the whole reply. @@ -128,7 +128,7 @@ of the fleet: the orchestrator takes no fleet file of its own. ### Waking a node When no running node serves the model and the fleet file's -[`wake`](fleet.md#waking) setting allows it, the gateway starts a node with +[`wake`](../fleet-file.md#waking) setting allows it, the gateway starts a node with the config that node's own Spinloop source resolves to — only nodes whose source describes the requested model are candidates, and a node whose stored config already matches is tried first — and holds the request until the engine diff --git a/docs/commands/harness.md b/docs/commands/harness.md index 10ce1443..4492df2a 100644 --- a/docs/commands/harness.md +++ b/docs/commands/harness.md @@ -149,11 +149,11 @@ using it — so a fleet with every machine busy on other models fails rather tha displacing anyone. `--no-wake` turns starting off entirely. Which node wins among several that could all serve you is a -[`prefer` setting](fleet.md#spreading-or-consolidating): `idle` (the default) +[`prefer` setting](../fleet-file.md#spreading-or-consolidating): `idle` (the default) takes the machine that has been quiet longest, keeping a second agent off an engine that is mid-request; `active` consolidates onto the busy one instead. -A fleet file that names a [gateway](fleet.md#gateway) points the agent there, +A fleet file that names a [gateway](../fleet-file.md#gateway) points the agent there, so the address lives in the file rather than in every Spinloop — and because a gateway resolves the model per request, a launch through one needs no Spinloop at all: `spinloop code --fleet ./fleet.yaml` is enough. @@ -314,7 +314,7 @@ Notes: that a file named `Spinloop` in the current directory — see [`spinloop alias`](alias.md#naming-one-for-the-whole-shell). - A URL ending in `/` is treated like a directory — `Spinloop` is appended. See - [Fetching a Spinloop from a URL](../spinloop-file.md#fetching-an-spinloop-from-a-url). + [Fetching a Spinloop from a URL](../spinloop-file.md#fetching-a-spinloop-from-a-url). - A Spinloop's `PRESET` line is for [`spinloop serve`](serve.md); `apply` ignores it — never fetched, even when it's a URL. - With `--env `, the Spinloop points at a registered diff --git a/docs/commands/orchestrator.md b/docs/commands/orchestrator.md index e4507414..02945173 100644 --- a/docs/commands/orchestrator.md +++ b/docs/commands/orchestrator.md @@ -3,7 +3,7 @@ Work a backlog of items against a [fleet](fleet.md) at a pace the fleet can absorb. The orchestrator holds the backlog, reads the fleet's topology from the fleet's [gateway](gateway.md), admits an item while the fleet's declared -[concurrency](fleet.md#concurrency) limits allow, and runs each admitted item +[concurrency](../fleet-file.md#concurrency) limits allow, and runs each admitted item as a one-shot agent of the [active harness](harness.md) in the item's own directory — the agent's inference going through that same gateway. @@ -59,7 +59,7 @@ directory the agent works in: `--create-item-dirs`, the orchestrator creates a missing directory instead. - **`priority`** — an integer, higher first; items of one rank go in file order. -- **`tags`** — `key=value` pairs naming the [tags](fleet.md#tags) of the nodes +- **`tags`** — `key=value` pairs naming the [tags](../fleet-file.md#tags) of the nodes the item may run on. Every pair must be a tag the node carries; an item with no tags may run anywhere. An item nothing matches waits — the fleet may change — rather than failing. @@ -164,7 +164,7 @@ an item matches a node only where every tag it names is one the node carries; a node already running and answering is offered before a node the run would have to start, and among a tier the fleet file's `prefer` ranks them. A stopped node is an option only where the fleet file -[wakes](fleet.md#waking). +[wakes](../fleet-file.md#waking). ## What it does not do @@ -180,7 +180,7 @@ stopped node is an option only where the fleet file - It never re-runs an ended item. `done` stands, and a `failed` one — including the ones a crash left in flight — stands. - It is not the fleet's scheduler. The limits it works to are the fleet - file's declared [concurrency](fleet.md#concurrency); it measures nothing and + file's declared [concurrency](../fleet-file.md#concurrency); it measures nothing and estimates no load. ## Flags diff --git a/docs/commands/serve.md b/docs/commands/serve.md index c345f19b..8779b228 100644 --- a/docs/commands/serve.md +++ b/docs/commands/serve.md @@ -175,7 +175,7 @@ against its URL, when the Spinloop itself was fetched from one — so the pair can travel together either way. `PRESET` may also be an absolute URL of its own, fetched only when `serve` builds the command, never merely because the Spinloop was read. See [Fetching a Spinloop from a -URL](../spinloop-file.md#fetching-an-spinloop-from-a-url). +URL](../spinloop-file.md#fetching-a-spinloop-from-a-url). ## oMLX diff --git a/docs/env-vars.md b/docs/env-vars.md index 6dc381af..d5b4a016 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -16,9 +16,9 @@ from the environment or a `.env` beside the Spinloop — never written into an | `SPINLOOP_API_TOKEN` | `spinloop daemon`, `spinloop serve --api`, `spinloop gateway` | Bearer token for the daemon control API — and the token a [gateway](commands/gateway.md)'s callers must present. One of three peer sources, alongside `--api-token-file` and `--api-token`; two at once is an error. From a service manager prefer the file form — see [serve](commands/serve.md). A non-loopback listen without any of them refuses to start. | | `SPINLOOP_REMOTE_KEYSTORE` | `spinloop remote auth` | Set to `file` to keep the stored control-plane credential in the owner-only file under the config directory, even where an OS keystore is reachable — the opt-out for a machine whose keystore is locked or unreachable. Unset, the OS keystore is used where available. See [credentials](commands/remote.md#credentials). | | `SPINLOOP_LOG_LEVEL` | `spinloop daemon`, `spinloop serve` | How much spinloop records about the control API and the supervised engine: `debug`, `info` (default), `warn` or `error`. Precedence: `--log-level` flag > `SPINLOOP_LOG_LEVEL` > `info`. An unrecognised value refuses to start rather than falling back to the default. Under `spinloop serve` the `.env` beside the Spinloop can set it; the daemon reads no Spinloop, so there it comes from the environment its service manager gives it. Records go to stderr; see [what gets logged](commands/serve.md#what-gets-logged). | -| *(per-node, named by `tokenEnv`)* | `spinloop fleet` | A fleet node's bearer token. `fleet.yaml` names the variable rather than holding the value; it resolves from the environment, then the `.env` beside the fleet file. See [fleet](commands/fleet.md). | -| *(per-node, named by `engineTokenEnv`)* | `spinloop fleet`, `spinloop harness open` | The key a fleet node's **engine** is gated with. Resolved the same way, and supplied by the client when it starts that engine — so the node holds no key of its own and the two ends cannot disagree. See [fleet](commands/fleet.md). | -| *(fleet-wide, named by `apiKeyEnv`)* | `spinloop fleet`, `spinloop harness open` | The default key for a `kind: remote` environment's engine, for every remote node that does not name its own `engineTokenEnv`. Resolved the same way. See [fleet](commands/fleet.md). | +| *(per-node, named by `tokenEnv`)* | `spinloop fleet` | A fleet node's bearer token. `fleet.yaml` names the variable rather than holding the value; it resolves from the environment, then the `.env` beside the fleet file. See [the fleet file](fleet-file.md#tokens). | +| *(per-node, named by `engineTokenEnv`)* | `spinloop fleet`, `spinloop harness open` | The key a fleet node's **engine** is gated with. Resolved the same way, and supplied by the client when it starts that engine — so the node holds no key of its own and the two ends cannot disagree. See [the fleet file](fleet-file.md#tokens). | +| *(fleet-wide, named by `apiKeyEnv`)* | `spinloop fleet`, `spinloop harness open` | The default key for a `kind: remote` environment's engine, for every remote node that does not name its own `engineTokenEnv`. Resolved the same way. See [the fleet file](fleet-file.md#tokens). | ## Remote (`spinloop remote`) diff --git a/docs/fleet-file.md b/docs/fleet-file.md new file mode 100644 index 00000000..d891e0ff --- /dev/null +++ b/docs/fleet-file.md @@ -0,0 +1,416 @@ +# The `fleet.yaml` file + +A **fleet file** is the `fleet.yaml` that names the machines you run — and how +to reach each one's [`spinloop daemon`](commands/serve.md#the-control-api---api-and-spinloop-daemon) — +so [`spinloop fleet`](commands/fleet.md) can observe and drive them all from one +place, and [`spinloop harness open`](commands/harness.md#launching-against-your-fleet) +can pick one of them for you. Like a [Spinloop](spinloop-file.md), it is a small +declarative file meant to be committed: it names the fleet, and it holds **no +secrets** — a token is named by the environment variable that holds it, never +written in the file. + +```yaml +# fleet.yaml — the machines, and how to reach each +prefer: idle # optional; idle (default) or active +wake: on # optional; on (default) or off +apiKeyEnv: REMOTE_KEY # optional; the default engine key for kind: remote nodes + +nodes: + - name: studio # required, unique; what you type at `fleet start ` + host: studio.local # required for a daemon node; a LAN name, tailscale name, or address + port: 4242 # optional; the daemon's control API port (4242 when omitted) + tokenEnv: STUDIO_TOKEN # optional; the *name* of the variable holding the daemon token + engineTokenEnv: STUDIO_ENGINE_KEY # optional; ditto, for the key its engine is gated with + file: ./studio/Spinloop # optional; what `fleet start`/`deploy` reads for this node + engine: # optional; where its *engine* answers, when the daemon cannot say + host: https://engine.example + port: 18080 + path: /v1 + + - name: qwen # for a kind: remote node, the registered environment's name + kind: remote + instance-type: g6e.2xlarge # optional; the EC2 type its environment launches as + +gateway: # optional; where a spinloop gateway serves this fleet + url: http://gateway.internal:4000 # required in the section, with a scheme + tokenEnv: GATEWAY_TOKEN # optional; OPENAI_API_KEY when absent + name: remote-llms # optional; labels the gateway in a model picker +``` + +The file is found the way a `Spinloop` is: `./fleet.yaml` in the working +directory, or `--fleet ` — short `-f` on every `spinloop fleet` +subcommand except `fleet logs`, where `-f` is the follow flag and the fleet +file takes the long form only. A missing file fails, naming the expected path +and how to create one. + +## Fields + +### Top level + +| Field | Required? | Meaning | +| ----------- | --------- | ---------------------------------------------------------------------------------------------------------- | +| `nodes` | yes | The node entries — at least one | +| `prefer` | no | `idle` (default) or `active` — how routing ranks several nodes that could all serve; see [Spreading or consolidating](#spreading-or-consolidating) | +| `wake` | no | `on` (default) or `off` — whether routing may start an engine on a node that is not running one; see [Waking](#waking) | +| `concurrency` | no | The most work the fleet may have in flight at once, for the [`spinloop orchestrator`](commands/orchestrator.md); see [Concurrency](#concurrency) | +| `apiKeyEnv` | no | The variable holding the key the fleet's `kind: remote` nodes share; see [Tokens](#tokens) | +| `gateway` | no | The address a [`spinloop gateway`](commands/gateway.md) serves the fleet under; see [Gateway](#gateway) | + +### A node + +| Field | Required? | Meaning | +| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `name` | yes | Unique in the file; what you type at `fleet start `. For a `kind: remote` node, the registered environment it drives | +| `host` | for `kind: daemon` | Where the daemon answers — a LAN name, a tailscale name, or an address | +| `port` | no | The daemon's control API port; 4242 when omitted | +| `kind` | no | `daemon` (default) or `remote`; see [Remote environments](#remote-environments) | +| `tokenEnv` | no | The variable holding this daemon's bearer token; none means no authentication (a loopback-only daemon) | +| `engineTokenEnv` | no | The variable holding the key this node's *engine* is gated with; see [Tokens](#tokens) | +| `engine` | no | An override of where the engine serves — `host`, `port`, `path`, each optional; see [Where a node's engine answers](#where-a-nodes-engine-answers) | +| `file` | no | The [Spinloop](spinloop-file.md) that describes what this node runs; see [A node's Spinloop source](#a-nodes-spinloop-source) | +| `instance-type` | no, `kind: remote` only | The EC2 instance type the environment launches as, e.g. `g6e.xlarge`; see [Remote environments](#remote-environments) | +| `tags` | no | Key/value pairs naming the kind of work the node takes on; only the [`spinloop orchestrator`](commands/orchestrator.md) reads them; see [Tags](#tags) | + +### The `gateway` section + +| Field | Required? | Meaning | +| ---------- | --------- | ------------------------------------------------------------------------------------------------ | +| `url` | yes | The gateway's address, carrying a scheme — `http://` or `https://` | +| `tokenEnv` | no | The variable holding the gateway's token; `OPENAI_API_KEY` when absent | +| `name` | no | The label the gateway wears in a model picker; the address's host when absent | + +## Where a node's engine answers + +A node's `host` and `port` name its **daemon**, which is a different port from +the **engine** it supervises. For [routing](commands/fleet.md#which-node-would-i-get) +spinloop needs the engine's, and the daemon reports it — so most nodes need +nothing more. Declare an `engine` block for the cases a daemon cannot describe: + +```yaml +nodes: + - name: containerised + host: docker-host + engine: + port: 18080 # published port, not the one it binds inside + + - name: proxied + host: node.local + engine: + host: https://engine.example # a reverse proxy in front of the engine + path: /openai # when it is not the usual /v1 +``` + +Each field falls back independently to what spinloop would otherwise derive: the +node's own `host`, and the port and path the daemon reports. + +An engine bound to loopback answers only on its own machine. Routing to it from +elsewhere fails with that explanation rather than a bare connection refused — +bind the engine to a reachable address (llama.cpp's `--host 0.0.0.0`), or +declare an `engine` block, which is you taking responsibility for reachability. + +## Remote environments + +`kind` (defaulted to `daemon`) says how the fleet reaches a node. A node can +also be an [`spinloop remote`](commands/remote.md) environment rather than a +machine: its `name` is the registered environment it drives — no `host` needed +— and it is reached through its control plane, which signs each call with your +AWS credentials, so it needs no bearer token: + +```yaml +nodes: + - name: qwen # the registered environment, and what you type at `fleet start ` + kind: remote +``` + +The environment's control URLs live in its `remote.json` (under +`~/.config/spinloop/remotes//`), written by `spinloop remote deploy` — or +by [`spinloop fleet deploy`](commands/fleet.md#deploying-remote-nodes), which +creates it from the fleet file itself — and never stored in the fleet file. So a +daemon and an environment sit side by side as the same kind of row, and an +environment that has not been deployed yet shows as `config-error` on its row +rather than blanking the fleet. See +[`examples/fleet-remote`](../examples/fleet-remote/README.md) and +[`examples/fleet-mixed`](../examples/fleet-mixed/README.md). + +A `kind: remote` node may also name the EC2 instance type its environment +launches as, with `instance-type` (a family and size separated by a dot, e.g. +`g6e.xlarge`): + +```yaml +nodes: + - name: qwen + kind: remote + instance-type: g6e.2xlarge +``` + +It is a property of the cloud environment, not of the fleet's view of it: +`fleet deploy` records it on the environment, and the environment's next +**fresh** launch uses it. A re-wake of a stopped instance keeps the type it was +launched with — EC2 cannot resize a running or stopped box — so a changed value +takes effect only after the instance is terminated (an idle sweep or +`spinloop remote stop`) and launched again. Omitted, the environment launches as +its control plane's default type. Naming `instance-type` on a `kind: daemon` +node is a configuration error: a daemon's hardware is the operator's to choose, +not the fleet file's. + +## A node's Spinloop source + +Both `fleet deploy` (for a `kind: remote` node's environment) and `fleet start` +(for a `kind: daemon` node's engine) need to know what Spinloop file describes +what a node runs. A node names it with `file`, resolved relative to the fleet +file: + +```yaml +nodes: + - name: qwen + kind: remote + file: ./envs/qwen.Spinloop +``` + +`file` is optional, because the node's own `name` already doubles as a lookup +key. When it is absent, resolution tries, in order: + +1. `name` registered as a `spinloop alias` (`spinloop alias add qwen + ./envs/qwen.Spinloop`) — the same lookup `spinloop remote deploy` performs + for a Spinloop argument; +2. a subdirectory named after the node, beside the fleet file — `qwen/Spinloop` + next to `fleet.yaml` for a node named `qwen`, no fields needed on either side. + +A fleet laid out as one subdirectory per node therefore needs nothing beyond +each node's own `name`: + +``` +fleet.yaml +qwen/Spinloop +llama/Spinloop +``` + +Nothing resolving is a per-node error naming all three ways a source could have +been given. For `fleet deploy` that always fails the node (there is nothing to +create an environment from); for `fleet start` on a `kind: daemon` node it +likewise fails that node's start — there is no fallback to a plain, config-less +start once this field exists. A `kind: remote` node's `start` is unaffected by +any of this: what it serves is fixed at deploy time, not pushed at start time. + +This does not apply to `spinloop fleet dashboard`'s `s` key, which still starts +the selected node with a plain start, whatever the CLI's `fleet start` would +resolve for it. + +## Spreading or consolidating + +`prefer` decides which node wins when several could all serve you: + +```yaml +prefer: idle # or: active +nodes: … +``` + +- **`idle`** (the default) — the machine quiet longest wins. A node that is + mid-request is the *least* idle of all, so it is the last one chosen. Use it + when several people share the fleet, or you run several agents at once. +- **`active`** — the most recently active wins, consolidating sessions onto one + engine and leaving the others free to be woken for another model, or left + asleep. + +`spinloop harness open --prefer ` and `spinloop fleet route --prefer ` +override the file for one command, which is the cheap way to see what the other +setting would do before committing to it. + +## Waking + +`wake` decides whether routing may start an engine on a node that is not +running one: + +```yaml +wake: off # or: on +nodes: … +``` + +- **`on`** (the default, and the behaviour of a file that declares nothing) — + when nothing is serving, spinloop starts a node and waits for its engine to + answer before the agent launches or the request is answered. +- **`off`** — a request nothing is serving fails rather than starting anything, + naming the node that would have been woken and the `spinloop fleet start + ` command that would start it. Use it where the machines are not to be + started on demand — the models are loaded by hand, or someone else drives the + starts. + +An explicit `--no-wake` still refuses to start anything, whatever the file +says; an explicit `spinloop fleet start` does the opposite — it always starts, +because it was asked. + +## Tags + +A node's `tags` name the kind of work the node takes on — key/value pairs the +operator chooses: + +```yaml +nodes: + - name: gpu-box + host: 198.51.100.7 + tags: + gpu: a100 + os: linux +``` + +They are how an [`spinloop orchestrator`](commands/orchestrator.md) item chooses its +node: an item names the tags of the nodes it may run on, and it matches a node +only where every one it names is a tag the node carries. A node with no tags +takes only items that name none. Routing, waking and the dashboard do not read +them — tags belong to the orchestrator's matching alone. + +## Concurrency + +`concurrency` is the pace this fleet works at, for the +[`spinloop orchestrator`](commands/orchestrator.md): how much work it may take at once. +The limits are a ceiling the operator sets, not a measurement of the engines' +load: + +```yaml +concurrency: + total: 8 # the most items the fleet may have in flight at once + tags: + "gpu=a100": 4 # and, per tag, the most in flight on nodes carrying it +``` + +An admitted item counts against `total` and against the limit of every tag it +names, and it frees its counts when it ends. A limit on a tag no node carries +is a configuration error naming the tag, as is a limit that is not a positive +integer. A file that declares no `concurrency` has no limit: the orchestrator +admits as fast as the nodes match. + +## Gateway + +`gateway` names the address this fleet is served under by a +[`spinloop gateway`](commands/gateway.md): a launch routed through this file is +pointed at the gateway rather than at a node. The gateway has done the choosing, +so the launch queries no node and wakes none: + +```yaml +nodes: … +gateway: + url: http://gateway.internal:4000 # required, with a scheme + tokenEnv: GATEWAY_TOKEN # optional; OPENAI_API_KEY when absent + name: remote-llms # optional; labels the gateway (see below) +``` + +A launch through such a file is pointed at the section's address — the agent's +base URL, with the OpenAI-compatible `/v1` prefix added when it carries no path +— and the token is resolved from the variable the section names — +`OPENAI_API_KEY` when it names none — the way a key is resolved elsewhere: an +`ENV` instruction, then the process environment, then the `.env` beside the +Spinloop. A variable set nowhere fails the launch before anything is written, +naming the variable. +As with a node's choice, the launch reports the address on stderr before the +agent starts. + +This is how a machine that holds the fleet file points a harness at the fleet: +A launch reads the section when it is there, so a Spinloop +beside the file needs only the model, and the address travels with the file. +`spinloop fleet route` answers a file that names a gateway the same way — the +address, and that no node is queried and nothing is started. + +When a launch through the gateway has no model of its own to route by (see +[Launching the harness](commands/fleet.md#launching-the-harness)), it needs a +way to label the provider it configures — otherwise every gateway a fleet might +name would collide under the same generic id. `name` supplies that label +directly; with none given, the section's address's host stands in (e.g. +`localhost:4000`). Either way opencode and Pi show it the way a remote +environment is shown — `Gateway (remote-llms)` rather than a bare +`OpenAI-compatible`, the same pattern as `llama.cpp (dev-2)`. + +## Tokens + +The file names secrets by the variables that hold them; the values are resolved +from the process environment first, then a `.env` beside the `fleet.yaml` — the +same precedence spinloop uses everywhere, so an exported value wins and the +`.env` only fills a gap. Put the secrets there: + +```sh +# .env beside fleet.yaml (gitignored) +GPU_BOX_TOKEN=… +``` + +There are three references, all resolved the same way: + +- **`tokenEnv`** — the node's daemon bearer token. A node with no `tokenEnv` + is contacted without authentication, which is correct for a daemon bound to + loopback. Any node reachable over the network needs a token — the daemon + refuses to listen on a non-loopback address without one. +- **`engineTokenEnv`** — the key the node's **engine** is gated with. The two + are different credentials — one authorises driving the node, the other + authorises using its engine — and a node may need either, both, or neither. + The daemon never hands its engine's key out: it says only that one is + required. +- **`apiKeyEnv`** (top level) — the default key for every `kind: remote` node. + A remote environment is always keyed, so a node that names no + `engineTokenEnv` of its own takes the fleet-wide reference. A node's own + `engineTokenEnv` overrides it, so one remote may carry a distinct key while + the rest of the fleet shares one. A `kind: daemon` node never takes the + fleet-wide reference: it is gated only by its own `engineTokenEnv`. + +```yaml + - name: gated + host: gated.local + tokenEnv: GATED_TOKEN # to drive the daemon + engineTokenEnv: GATED_ENGINE_KEY # to talk to its engine +``` + +A `kind: remote` environment is always keyed, so it needs an engine key too — +its `engineTokenEnv` works as above, and a fleet-wide `apiKeyEnv` is the +default for every remote node that does not name one of its own: + +```yaml +apiKeyEnv: REMOTE_ENGINE_KEY # the default for every kind: remote node +nodes: + - name: qwen + kind: remote + engineTokenEnv: OTHER_KEY # overrides it for this node +``` + +A reference naming a variable that is set nowhere is reported against that +node, naming the variable, in the same way a missing daemon token is — on the +row as `config-error`, so a typo shows up there rather than as a mysterious +`unauthorized`, and a launch fails before it starts the agent rather than +pointing it at a gate it cannot pass. + +## What the file refuses + +A fleet file is checked when it is read, and a file that fails is refused with +the reason named: + +- no `nodes` — list at least one; +- a node with no `name`, or two nodes with the same name; +- a `kind: daemon` node with no `host`; +- an unknown `kind` — only `daemon` and `remote` are supported; +- a `kind: remote` node whose `name` is not shaped like a registered + environment name (no `/`, no trailing `.json`) — the name is the key of the + environment it drives; +- an `instance-type` that is not shaped like an EC2 instance type (a family and + size separated by a dot, e.g. `g6e.xlarge`), and `instance-type` on a + `kind: daemon` node at all; +- a `prefer` other than `idle` or `active`, naming both accepted values; +- a `wake` other than `on` or `off`, naming both accepted values; +- a `gateway` section with no `url`, and a `url` with no scheme. + +Token references are not checked at read time — a `tokenEnv` that resolves to +nothing shows up per node when a command needs it, as described under +[Tokens](#tokens). + +## Examples + +Fleet files, each with a walkthrough: + +- [`examples/fleet-local/`](../examples/fleet-local/) — a fleet of one, on your own machine +- [`examples/fleet/`](../examples/fleet/) — a small LAN fleet, all defaults +- [`examples/fleet-docker/`](../examples/fleet-docker/) — a runnable multi-node fleet in containers +- [`examples/fleet-remote/`](../examples/fleet-remote/) — a fleet of cloud environments +- [`examples/fleet-mixed/`](../examples/fleet-mixed/) — daemons and environments side by side +- [`examples/gateway-docker/`](../examples/gateway-docker/) — a fleet behind its gateway + +## See also + +- [`spinloop fleet`](commands/fleet.md) — the commands that read this file +- [`spinloop gateway`](commands/gateway.md) — the server a `gateway` section names +- [The `Spinloop` file](spinloop-file.md) — the format a node's `file` points at +- [`spinloop daemon`](commands/serve.md) — what runs on each node diff --git a/docs/spinloop-file.md b/docs/spinloop-file.md index df162f2c..69281af7 100644 --- a/docs/spinloop-file.md +++ b/docs/spinloop-file.md @@ -135,7 +135,7 @@ machine. ## Running the model on another machine you own -A [fleet file](commands/fleet.md#fleetyaml) names the machines on your network +A [fleet file](fleet-file.md) names the machines on your network running `spinloop daemon`, and `spinloop harness open` can pick one for you. Which fleet file a launch routes through is a launch concern, not a Spinloop field: diff --git a/docs/work-items.md b/docs/work-items.md index 08df0f04..72cc4260 100644 --- a/docs/work-items.md +++ b/docs/work-items.md @@ -11,9 +11,9 @@ only to find that gateway, and holds no node credentials of its own. Three things, all described on their own pages: -- A [fleet file](commands/fleet.md) whose nodes carry [tags](commands/fleet.md#tags) — +- A [fleet file](fleet-file.md) whose nodes carry [tags](fleet-file.md#tags) — the operator's description of what work each node can take on — and which - declares its [concurrency limits](commands/fleet.md#concurrency), how much + declares its [concurrency limits](fleet-file.md#concurrency), how much work the fleet may hold in flight at once - A [gateway](commands/gateway.md) in front of that fleet, which is how the orchestrator sees the nodes and how each agent's requests reach them — @@ -62,7 +62,7 @@ export OPENAI_API_KEY=the-gateway-token spinloop orchestrator --gateway http://127.0.0.1:4100 --items ./work.yaml ``` -Where the fleet file's [gateway section](commands/fleet.md#gateway) names the gateway +Where the fleet file's [gateway section](fleet-file.md#gateway) names the gateway — and you run from the directory the file lives in — the flag stands down: ```sh @@ -120,7 +120,7 @@ file left off. An orchestrator that dies uncleanly leaves its items marked ## What it does not do It never starts or stops a node — a stopped node is offered to an item only -where the fleet's [wake policy](commands/fleet.md#waking) says a request may +where the fleet's [wake policy](fleet-file.md#waking) says a request may start it, and it is the gateway that does the starting. It does not re-run an item that has ended, finished or failed, and its run holds no node credentials: the gateway is its only view of the fleet, the fleet file diff --git a/examples/fleet-mixed/README.md b/examples/fleet-mixed/README.md index ee21eaca..60116667 100644 --- a/examples/fleet-mixed/README.md +++ b/examples/fleet-mixed/README.md @@ -32,7 +32,7 @@ the two environments — so none of them declares a `file` field in the same way, and win over the subdirectory; an explicit `file` field can point anywhere else again, which is what [`examples/fleet-docker`](../fleet-docker/) uses instead. See -[`spinloop fleet`](../../docs/commands/fleet.md#a-nodes-spinloop-source) for +[the fleet file](../../docs/fleet-file.md#a-nodes-spinloop-source) for the full resolution order. ```sh @@ -62,4 +62,5 @@ and the rest of the fleet still shows. - [`examples/fleet`](../fleet/README.md) — a fleet of daemons only - [`examples/fleet-remote`](../fleet-remote/README.md) — a fleet of remote environments only -- [`spinloop fleet`](../../docs/commands/fleet.md) — the fleet file, its node kinds, and routing +- [`spinloop fleet`](../../docs/commands/fleet.md) — the commands over these nodes +- [The fleet file](../../docs/fleet-file.md) — the file's format, node kinds, and routing diff --git a/examples/fleet-remote/README.md b/examples/fleet-remote/README.md index a780b2fc..75e51eaf 100644 --- a/examples/fleet-remote/README.md +++ b/examples/fleet-remote/README.md @@ -52,7 +52,7 @@ would resolve the same way, and win over the subdirectory if both existed — or a `file` field can point anywhere else entirely, which is what [`examples/fleet-docker`](../fleet-docker/) uses to reuse one Spinloop (`client/Spinloop`) whose name matches neither node that runs it. See -[`spinloop fleet`](../../docs/commands/fleet.md#a-nodes-spinloop-source) for +[the fleet file](../../docs/fleet-file.md#a-nodes-spinloop-source) for the full resolution order. ### 3. Observe from anywhere @@ -72,5 +72,6 @@ fleet still renders. ## See also -- [`spinloop fleet`](../../docs/commands/fleet.md) — the fleet file, its node kinds, and routing +- [`spinloop fleet`](../../docs/commands/fleet.md) — the commands over these nodes +- [The fleet file](../../docs/fleet-file.md) — the file's format, node kinds, and routing - [`spinloop remote`](../../docs/commands/remote.md) — the environments these nodes drive diff --git a/examples/gateway-docker/README.md b/examples/gateway-docker/README.md index 1bd6ded8..2963caaa 100644 --- a/examples/gateway-docker/README.md +++ b/examples/gateway-docker/README.md @@ -151,5 +151,5 @@ Two details that are easy to get wrong, and matter: - [`examples/fleet-docker/`](../fleet-docker/) — a plain fleet, no gateway - [`docs/commands/gateway.md`](../../docs/commands/gateway.md) -- [`docs/commands/fleet.md`](../../docs/commands/fleet.md#the-gateway-section) — the `gateway` section +- [The fleet file's `gateway` section](../../docs/fleet-file.md#gateway) - [HTTP Control API](../../docs/http-api.md) diff --git a/examples/remote-spinloop/README.md b/examples/remote-spinloop/README.md index c842ce2c..8e0fe3c8 100644 --- a/examples/remote-spinloop/README.md +++ b/examples/remote-spinloop/README.md @@ -68,7 +68,7 @@ spinloop harness apply qwen3.6-27b-team # never touches preset.ini Nothing here is fetched until the command that actually needs it runs — applying the Spinloop doesn't fetch the preset, and registering the alias doesn't either. See [Fetching a Spinloop from a -URL](../../docs/spinloop-file.md#fetching-an-spinloop-from-a-url) for the full +URL](../../docs/spinloop-file.md#fetching-a-spinloop-from-a-url) for the full picture. ## See also