AGENTS.md
Rama main · réplica de lectura del repositorio original.
# Agent guidance
Shared guidance for coding agents working in this repository.
## What this is
Zoomies is a self-hosted GitHub Actions runner fleet controller: one Go binary
that watches for queued jobs, starts an ephemeral runner container per job, and
destroys it when the job finishes. SQLite for state, a Svelte 5 UI embedded with
`go:embed`, no Kubernetes and no database server.
`cmd/zoomies` is the only binary. It is the controller (`zoomies controller`),
the agent (`zoomies agent`), the installer (`zoomies init`) and the CLI, chosen
by subcommand. On a single VM the controller runs an agent inside itself, so the
whole system is one process and one SQLite file.
Read [docs/architecture.md](docs/architecture.md) before changing anything
structural; it explains the shape and the reasons for it.
## Commands
```sh
make build-nogui # build without rebuilding the UI (the fast inner loop)
make build # build the UI and embed it (needs npm)
make test # go test -race -count=1 ./...
make bench # the benchmarks, six runs each; compare two runs with benchstat (BENCH, BENCH_PKGS, BENCH_COUNT narrow it)
make lint # go vet, gofmt check, staticcheck if present, UI lint
make fmt # gofmt + prettier
make dev # controller with auth off, on :8080, for UI work
make ui-dev # Vite dev server proxying /api to that controller
make test-unit # the UI's unit tests: no browser, a few seconds
make test-ui # Playwright against a real built binary
make openapi # regenerate both copies of api/openapi.yaml: the embedded Go source and the UI's TypeScript client
make screenshots # recapture docs/screenshots from the real UI, both themes (needs Pillow)
```
`go build ./...` fails on a clean checkout: `internal/api` embeds
`internal/api/webdist`, which is a build product. Run `make build-nogui` once,
it writes a placeholder, before any plain Go command (`go build`, `go vet`,
`go test`) that compiles that package. The `make` targets that run Go (`test`,
`test-short`, `bench`, `cover`, `lint`, `generate`) write the placeholder
themselves, and never over a real UI build. CI does the same thing as its first
step.
Go 1.27.1 or later (`go.mod` sets the floor; CI builds with 1.27.2), Node 22 or
later. Node is a build-time dependency only; the shipped binary is
self-contained and static (`CGO_ENABLED=0`, pure-Go SQLite).
Run a single test with `go test -run TestName ./internal/pkg/`. `make test-e2e`
needs real GitHub credentials and skips itself without them.
A change made for speed is held to a number: run `make bench` before and after
(on an otherwise idle machine, once per package, `tee`d to files) and put the
`benchstat` comparison in the commit message. `CONTRIBUTING.md` has the
commands. A benchmark checks that its scenario is the one its name describes
before it times anything; keep that when adding one.
## Package boundaries
These are load-bearing. Code that crosses them is the main thing to catch in
review.
| Package | Rule |
| --- | --- |
| `internal/store` | The **only** place SQL is written. Domain types, embedded migrations, every query. No other package imports `database/sql`. |
| `internal/scheduler` | **Pure.** `Decide` takes a snapshot and returns a `Plan`. No clock reads, no database, no network; that is what makes scaling behaviour testable, and it is where every decision's operator-facing *reason string* comes from. |
| `internal/updates` | **Pure.** `Choose` takes a list of releases, the mode, the soak and the instant, and picks the release the mode would take and says why. It imports the standard library and `internal/version` and reads no clock; `purity_test.go` enforces both. `Decide`, the planner for automatic updates, has joined it: it takes a snapshot and returns the actions and the sentence the status shows, under the same rules. |
| `internal/updates/channel` | The impure half of `internal/updates`: finds the update folder, writes a request into it and reads what the helper left. It is the only place the service touches that folder, and it never creates it (the installer does, with the service account's ownership). |
| `internal/api` | Transport only. A handler reads a request, asks the controller / auth / store, and renders the shape `api/openapi.yaml` promises. It has no opinions about the fleet. The resource views themselves (`HostView`, `PoolView`, …) are `internal/controller/views.go` types the handlers alias, because the event stream renders the same JSON and is fed from the controller. |
| `internal/provider` | The infrastructure-provider contract, a fake that obeys it, and `RunContractTests`, the conformance suite every provider passes. It may import `internal/store`'s domain types and `config.Finding` and **nothing else**; two tests enforce that, and that it names nothing provider-specific. Renting a machine is not placing a runner: this is separate from `internal/backend` on purpose. |
| `internal/cacheservice` | **Stdlib only.** The host-local Actions cache protocol (`actions/cache`) and nothing about the fleet: no store, no config, no controller, no clock of its own. The agent decides where and when it runs. `purity_test.go` enforces the imports. Every failure it cannot answer is a miss, never a failed build. |
| `internal/provider/proxmox` | The first provider. The Proxmox VE API is hand-rolled `net/http` for the same reason the Docker one is. |
| `internal/controller` | Wiring: the reconcile loop, the machine loop, webhook ingest, the agent task queue, the log relay, and every payload the event stream carries (`views.go`, `derived.go`). The machine loop is **not** part of `Reconcile`: a clone takes minutes and `reconcileMu` is held for a whole scheduling pass. |
| `internal/config` | `zoomies.yaml` + `ZOOMIES_*` overrides, and the validator. |
| `internal/github` | App auth, JIT configs, webhook validation, the fallback poller, and `fake.go`, a fake GitHub used by tests. |
| `internal/backend` | Docker, Podman, bare process. The Docker API is hand-rolled `net/http` against the Engine API on purpose (see below). |
| `internal/agent` | The runner-executing half and its outbound transport. |
| `internal/mcp` | The MCP tools and both transports (stdio for `zoomies mcp`, Streamable HTTP for `/mcp`). A tool calls the REST API through the `mcp.API` interface and nothing else (no store, no controller), so it can never do what the caller's token could not. |
The rules that can be stated as an import are held by a test, not by review
alone: `internal/scheduler`, `internal/updates` and `internal/provider` each have
a purity or boundary test, `internal/mcp` may import only what its test lists,
and a test in `internal/store` fails if any other package's shipped code imports
`database/sql` or the SQLite driver. A test that fails on one of these names the
rule; when the import is a pure helper that belongs, add it to that test's
allowlist with a reason rather than loosening the rule.
Other invariants worth knowing before you edit:
* **The controller never dials an agent.** Agents connect outbound only (long-poll
for tasks, POST results), so a host behind NAT needs no inbound rule. That is
why log streaming is inverted: the controller queues a `stream_logs` task and
the agent opens a chunked POST that gets relayed to the browser's SSE stream.
* **Webhook deliveries are at-least-once and can arrive out of order.** The jobs
upsert refuses to move a job backwards through its lifecycle. Keep it that way.
* **Every `*.updated` event is the resource's `GET` shape.** The UI drops a frame
straight into its cache, so a `host.updated` carrying a bare store row (no
`healthy`, no `free`) repaints the host wrong. Publish through the
controller's `publish*`/`Publish*` helpers, which render the view; never put a
store row on the bus. `stats`, `problems.updated` and each host's view are
computed by the pass and the housekeeping tick and sent only when they change,
so a new kind of problem (or a host whose slots or heartbeat moved with no row
written) needs no publish call of its own. They are paced (`publishDerivedPaced`
leaves them alone for five seconds at least, longer when they are dear, and a
pass it turns away sets a timer that nudges the scheduler when the gap is over)
and each is worked out only if some subscriber asked for its kind
(`Bus.Wants`); what an operator just did calls `publishDerived`, which is
neither, or nudges with `NudgeOperator` (as every handler that edits something
does, and `UpdateConfig`), which skips the pacing once. A handler that edits
something and calls plain `Nudge` leaves the problems bell and the tiles
standing still for the rest of the gap.
Read the scoped guidance when working in these directories:
* [`internal/store/AGENTS.md`](internal/store/AGENTS.md): one writer, the runner state machine, sentinel errors, IDs.
* [`internal/config/AGENTS.md`](internal/config/AGENTS.md): configuration: settings live in the database.
* [`web/AGENTS.md`](web/AGENTS.md): the UI: tokens, status colours, no new libraries.
* [`internal/hosttune/AGENTS.md`](internal/hosttune/AGENTS.md): host health and tuning: consent, never tune from an upgrade, never restart Docker under work.
* [`.github/AGENTS.md`](.github/AGENTS.md): "Things CI will fail you on": the files CI diffs against their sources.
## Dependencies
Every dependency carries a one-line justification in
[docs/dependencies.md](docs/dependencies.md), and that is enforced by review.
Before adding one: could the standard library or fifty lines of our own do it?
Is it maintained? What does it pull in transitively? What does it cost against
the shell budget if it ships to the browser? Then add the row, in the same
voice, *why*, not *what*.
Some omissions are deliberate and documented there, notably
`github.com/docker/docker` (the hand-rolled Engine API client in
`internal/backend/dockerapi.go` is a few hundred lines and gets Podman almost
free, because `podman.sock` speaks the same protocol).
## Testing
Table-driven and standard-library `testing` throughout; no assertion framework.
Tests use `:memory:` SQLite stores, injected clocks (`store.Options.Now`), and
the fake GitHub in `internal/github/fake.go` rather than network calls. Helper
constructors take `t` and call `t.Helper()` / `t.Cleanup`. Coverage is broad
(roughly one test file for every source file) so a new behaviour is expected to
arrive with one.
Test names are sentences about the behaviour, not the method
(`TestOpenCreatesTheDatabaseDirectory`), and a test often carries a comment
explaining *why the behaviour matters*, not what the code does.
## Voice
The prose in this repository (comments, docs, error messages, commit messages)
has a consistent voice, and matching it is part of a change looking finished.
* **British spelling** in prose: *behaviour*, *authorisation*, *organisation*,
*utilisation*, *licence*. (JSON field names mirroring GitHub's API stay
American: `organization` in a webhook payload is correct as-is.)
* **Comments explain why, not what.** The interesting ones name the failure mode
being designed out, or the trade-off taken. Follow that; do not narrate code.
* **Error and warning messages are written for a person to act on.** An operator
who gets a 403 should be told which role they are missing. Findings say what
to change; API errors carry a human message and a stable code.
* **No em dashes.** Do not write `—` anywhere in this repository: prose,
docs, comments, error messages, UI strings, commit messages or pull request
text. Use a comma, a colon, a semicolon or parentheses, or split the sentence.
Do not use `--` as a stand-in either. `internal/docs/no_em_dash_test.go`
fails the build on a spaced em dash, and `internal/docs/double_hyphen_test.go`
on a file that gains a spaced `--`. Older code comments still carry about six
thousand of the latter, and they go as their lines are rewritten, not in one
sweep that would bury every file's history. The one exception is the UI's
placeholder for an absent value, which is two hyphens: write it as `NO_VALUE`
from `web/src/lib/format.ts`, never as a dash of your own;
`web/unit/absent-value.test.ts` guards it.
* **Diagrams are Mermaid**, in a `mermaid` fenced block beside the prose they
explain, never ASCII art. The site renders them (`mkdocs.yml` registers the
fence) and so does GitHub, so a diagram lives in the Markdown it belongs to and
there is no exported image to go stale. Do not colour one by hand: Material
themes it for light and dark, and a hard-coded fill is wrong in one of them.
* **Commit messages are imperative sentences in plain prose**, sentence case, no
Conventional Commits prefix: *"Say why a pool has no host, and let a host say
it can run again"*, *"Make both halves of 'no capacity' something an operator
can do"*.
## Layout
```text
cmd/zoomies the binary: controller, agent, gateway, init, CLI
internal/store domain model, SQLite schema, every query
internal/config zoomies.yaml + env, and the validator that warns
internal/scheduler pure scaling decisions and label matching
internal/updates which release an update mode would take, and why; pure
internal/updates/channel the update folder: the service's side of the helper's files
internal/github App auth, JIT configs, webhooks, the fallback poller
internal/backend Docker, Podman and bare-process runner backends
internal/auth identity, RBAC, tokens, audit, OIDC
internal/api REST, SSE, metrics, and the embedded UI
internal/provider the infrastructure-provider contract, its fake, and Proxmox
internal/cacheservice the Actions cache protocol, served from a host's own disk
internal/registry what a container registry says a tag points at, for the digest-pinning generators
internal/controller the reconcile loop, the machine loop and the agent task queue
internal/agent the runner-executing half
internal/gateway zoomies gateway: a private provider's end of the tunnel
internal/installer zoomies init / uninstall / agent join, and the unit,
compose and env templates they write
internal/backup one copy of the database: taking, listing, verifying, archiving,
encrypting, shipping it to an S3-compatible remote and restoring it,
shared by the CLI, the scheduler and the API
internal/cryptox AES-256-GCM at rest, argon2id, token hashing
internal/events in-process pub/sub that the SSE endpoint fans out
internal/migrate rewriting workflows' runs-on lines
internal/mcp the fleet over MCP: tools, stdio and Streamable HTTP
internal/aicontext commit-pinned repository context, independent of the fleet
internal/agentguidance inspects a repository's agent instruction files without running them
internal/analysis evidence-backed findings computed from explicit snapshots
internal/assistant the model-provider contract behind the assistant, and its adapters
internal/catalog the one machine-readable list of problem codes and Kennel Club checks
internal/cliconfig ~/.config/zoomies/cli.yaml: where the CLI finds its controller
internal/conflictresolve asks a model to resolve the text conflicts a merge worker found; edits only those files
internal/docs tests that hold the documentation to the code it describes; no source of its own
internal/hosttune host health and tuning: detection that never writes, and approved changes
internal/kennel pure evaluation of what is wrong with a repository's workflows
internal/logging the process-wide structured logger
internal/machine which host this process runs on: distribution, release, capacity
internal/naming the grammar for pool, host and runner names
internal/optimisation reviewable proposals computed from evidence handed in; reads no source and calls no model
internal/provenance keeps third-party names out of the tree
internal/proxmoxsetup connecting a Proxmox host: its own principal, binary, service and state
internal/prrepair bounds what the model may edit when it proposes a pull request
internal/gitmerge the bounded, isolated three-way merge for Eli conflict repair
internal/gitbare a bare repository built from a commit's archive, so a controller without Git can hand the worker one
internal/gitfetch the three pinned commits fetched into that repository, with the tree check and the cleanup
internal/qr the smallest QR encoder that serves the two-step verification picture
internal/redact strips credentials and commit emails from text bound for a model
internal/releasekey the release signing key: Ed25519 verification of a release's checksums
internal/toolscan reads workflows for the toolchains their jobs install
internal/terminalui terminal presentation: the CLI home, paw indicator and command progress
internal/version build metadata stamped in at link time
internal/wsl keeps an agent alive inside a WSL2 distro
web/ the Svelte 5 UI
test/e2e the Docker end-to-end test, behind the `e2e` build tag
api/openapi.yaml the API contract both clients are generated from
deploy/ the controller and runner images, and the runner entrypoint
design/ the Mission Control style review: concept images and their validation
docker-compose.yml the compose deployment, at the root so it is the one people find
docs/ the zoomies.sh site, built by mkdocs.yml
overrides/ the site's theme overrides: sharing tags, structured data, sitemap,
the header's repository facts
hooks/ the site's build-time metadata: git dates, llms.txt, and the
latest release and star count the header shows
site-worker/ the edge that serves zoomies.sh: the built site as the
worker's static assets, content negotiation for agents,
the discovery mirrors and every response header the
assets pipeline cannot set
weekly-seo/ the weekly search check for zoomies.sh: its config, notes and
the report and memory files the .claude/agents/weekly-seo.md
agent writes
ROADMAP.md the follow-on roadmap, and the decisions it asks the owner to take
roadmap/ what supports it: the work-package record, decision records,
gate evidence, dated plans, the model guidance, the source
document and an archive of finished ones; its README says which
document answers which question
skills/ what a coding agent installs: the zoomies skill, with a command
reference generated from the binary
plans/ the improve advisor's output: prioritised, self-contained
implementation plans and their index, each stamped with the
commit it was written against; executors update the status
table in plans/README.md
install.sh the one-line installer, served from the site root
nixpacks.toml the build a Nixpacks-based PaaS runs; see docs/paas.md
```
## Instruction hierarchy
`AGENTS.md` contains shared engineering guidance. Claude imports it through
`CLAUDE.md`. Scoped `AGENTS.md` files add detail beside the code; their Claude
wrappers import the same rules. Fix contradictions rather than relying on load
order. Keep entry points concise and put retrieval detail in the context guide.
## Host health implementation
Follow the consent rules in `internal/hosttune/AGENTS.md` and
`docs/host-health.md`. Upgrades are doctor-only. Container health comes from
the native host binary; no privileged container mounts. All host-tuning tests
use injected filesystems and commands. Preserve reversal records on uninstall.
<!-- zoomies-ai-context:start -->
## Zoomies AI Context
Before using prepared repository context, read `.zoomies/AI_CONTEXT.md`. It explains available access routes, revision checks and selective retrieval.
<!-- zoomies-ai-context:end -->
## Agent skills
### Eli Repair implementation
Before changing Eli PR repairs, read
[`roadmap/eli-repair-implementation-handoff.md`](roadmap/eli-repair-implementation-handoff.md).
Every PR for this work must update that file in the same PR: record the actual
scope, implementation status, validation results, PR link and next unfinished
step. Keep planned capabilities distinct from shipped ones. A new session must
be able to continue from the file without the chat history.
### Issue tracker
Issues live in GitHub Issues for eyupio/zoomies, driven by the `gh` CLI. See `docs/agents/issue-tracker.md`.
### Domain docs
Single-context: one `GLOSSARY.md` at the repo root, and the numbered decision records in `roadmap/decisions/`. See `docs/agents/domain.md`.