Add AGENTS.md and align the docs with the code

The brief asked for an AGENTS.md capturing the overall plan and it never
landed, while two files already pointed readers at it. It now holds the
invariants (read-only by construction, never invent a number,
unconfigured is not broken, no CDN, no guessed metric names), the locked
decisions with their reasons, the verified fleet facts, the commands, and
the four known gaps.

Also corrected the docs that had drifted:
- upsd config lives in /etc/ups on driveripper, not /etc/nut, and the
  real blocker is that upsd binds nothing on 3493 — with the sudo rules
  still needed written down in deploy/nut-sudoers.
- .env.example told operators to set `LISTEN 0.0.0.0` and `actions = -`;
  both are worse than needed. Loopback + ssh forward, and per-command
  instcmds instead of granting load.off.
- nut.py claimed the command path is gated three times and that the
  confirmation asks for the UPS name; it is gated four times and asks
  for DANGER.
- .gitignore described a tracked .agents/ roadmap that does not exist.

LLM-Generated: true
This commit is contained in:
2026-10-16 18:54:35 -04:00
parent 426682a999
commit 87042f8855
8 changed files with 347 additions and 17 deletions
+34 -7
View File
@@ -12,6 +12,10 @@ Three dashboards, one domain, one process, one SQLite file:
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
runtime; every asset is served by this app.
Working on this repo? **[`AGENTS.md`](AGENTS.md)** holds the invariants (read-only by
construction, never invent a number, unconfigured ≠ broken), the locked decisions, the real
fleet facts, and the known gaps.
## Quickstart
```bash
@@ -33,11 +37,17 @@ for host in ("driveripper-ops", "deskwork-ops", "gateway-ops"):
subprocess.run(["ssh", host, "mkdir -p ~/lab-agent"], check=True)
subprocess.run(["scp", "agent/host_agent.py", f"{host}:~/lab-agent/"], check=True)
PY
./scripts/dev-tunnels.sh start # 9121/9122/9123 -> each host's agent
./scripts/dev-tunnels.sh start # 9121/9122/9123 -> each host's agent,
# + DEV_FORWARDS -> upsd on 127.0.0.1:3494
set -a; . ./.env.dev; set +a
uv run dashboards
uv run dashboards # foreground
./scripts/dev-server.sh restart # or backgrounded, with a pidfile + logs
```
`DEV_TUNNELS` starts the agent remotely and forwards it; `DEV_FORWARDS` forwards a service
that already listens there (upsd) — that is how the UPS page reads a real battery without
3493 being reachable from anywhere but this laptop.
## The host agent
`agent/host_agent.py` is one file, **stdlib only**, Python 3.10+. It reads `/proc`,
@@ -115,9 +125,9 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ──
## Testing
```bash
uv run pytest # unit + API tests, fully mocked
uv run pytest # unit + API tests, fully mocked, no network
uv run pytest --cov # coverage gate
uv run pytest -m live --live # optional: hit the real fleet/engines
uv run pytest -m live --live # optional: real fleet / engines / upsd
uv run ruff check src tests agent && uv run pyright # lint + types
./scripts/capture-fixtures.sh # re-record ground-truth captures
```
@@ -125,18 +135,35 @@ uv run ruff check src tests agent && uv run pyright # lint + types
Test philosophy: **real captures as fixtures, mocks for behaviour.** Anything that must
match an upstream (`/proc` layouts, vLLM metric names, NUT protocol framing) is asserted
against a verbatim capture in `tests/fixtures/`; behaviour that needs a failure — upsd
refusing a command, an engine returning 500, a host rebooting — is a fake. `-m live`
opt-in tests are the bridge: they run only when you point them at real hardware.
refusing a command, an engine returning 500, a host rebooting — is a fake. The `live` tests
(`tests/test_live_fleet.py`) are the bridge: they SKIP unless you pass `--live`, each one
also skips when its source is unset, they drive the production code paths (`collectors.*`,
`NutMonitor.collect`) against whatever hardware is reachable, and they never write — a
battery test on live hardware is not a unit test.
## Battery-test buttons
Instant commands are the only mutating endpoint in the application (`POST /api/ups/command`)
and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users`
account that actually has `actions`, and a confirmation in the UI — destructive ones
account that actually has the command, and a confirmation in the UI — destructive ones
(deep test, calibration, `load.off`) require typing `DANGER`. The server re-checks every
gate; hiding a button is not a safety control. Commands that would cut power are refused
outright while the UPS is already on battery.
Give the app a narrowly scoped NUT user instead of a god-mode one — per-command `instcmds`,
not `actions = -`:
```ini
# /etc/ups/upsd.users
[dashboards]
password = "..."
instcmds = test.battery.start.quick test.battery.start.deep test.battery.stop
```
Bind upsd to loopback (`LISTEN 127.0.0.1` in `upsd.conf`) and reach it with an ssh forward
(`DEV_FORWARDS`) or a tailnet address. `NUT_SIMULATE=1` stands in for upsd during
development; everything it produces is badged `SIMULATED`, and it is never the default.
## Deployment
`Containerfile` is a two-stage build: `uv.lock` is resolved in the builder (exactly the