phase: 10_dev_direct_dial

**Phase 10 final pass — repo green; live criteria blocked off-repo.**

- Verified: `gateway` runs `dashboards-agent` (active/enabled, `10.1.1.27:9120` → `/health` 200, `/snapshot` 401 no-token / 200 bearer on-host, token matches `/etc/dashboards-agent.token`); `driveripper`/`deskwork` inactive, `sudo -n` → "a password is required", `:9120` empty.
- New measurement (transient runtime rule on gateway `firewall-cmd --add-port=9120/tcp`, **reverted** — runtime+permanent port lists verified back to the original set, no rich rules): laptop still gets no SYN through, while `:22`/`:80` on the same address answer in ms ⇒ the block is the lab gateway `10.2.0.1` port filter, not this app/host firewall/agent. No other live change made (no sudoers, no upsd, no UPS command).
- Tests/lint (exact): `bash .agents/validate.sh` → **EXIT 0** — `uv run pytest --cov --cov-fail-under=94` **793 passed, 11 skipped, coverage 98.33 %**; `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean; hygiene clean; `uv pip list --outdated` empty (bumped fastapi 0.143.3→0.143.5, `uv.lock` only).
- `uv run pytest -m live --live` (`.env.dev` sourced): **4 passed, 1 skipped, 6 failed** — all six are `:9120`/`:3051` connect timeouts; zero parse/contract failures.
- Dev server restarted on current code: engines `ok`; 3×`host` + 3×`disks` + `nut` `DOWN` with real reasons (designed degradation, stub rows intact, no credential leakage).
- Completion criteria: screenshots ✓ (`data/logs/phase10-{index,system,disks,llm,ups}-live.png`); tunnel-vocabulary grep ✓ outside `.agents/` history (PLAN.md locked line, phase overviews, past reports; fixtures/`uv.lock` hits are digit substrings — `tests/test_deploy_scripts.py` guards operational files); no page-payload change ✓ (contract tests). **Not met, operator-side:** agents on all three hosts, `LIST UPS` from the laptop, `dev-server.sh status` all-ok, live suite green.
- Fixed in this pass: `sources/nut.py` unreachable-upsd message no longer prints a bare `(TimeoutError: )` (now `(timed out after 4s)` / `(ConnectionRefusedError: …)`), pinned by a new test + extended refused-port test.
- Docs: README verified consistent (every env var in `config.py` documented, routes/flags/`--with-llm`/pager sizes checked) — no changes needed. AGENTS.md fixed 4 inconsistencies: per-host sudo reality (gateway grants `NOPASSWD ALL`, which is why it installed without `agent-sudoers`; driveripper scoped; deskwork unrelated), the now-proven firewall finding, `/system` table typo ("agent per host"), gap-4 DB/WAL sizes + test count. `.env.example` `upsd.users` sample now one `instcmds` per line (it contradicted README and the in-app copy). `.env.dev` (gitignored) comment corrected to match the evidence.
- Flagged, untouched (locked): `.agents/PLAN.md` L92–95 still says upsd is "forwarded to `127.0.0.1:3494` in dev" — false after this phase; evidence + exact operator commands appended to `data/logs/NOTES-verify.md` §11.
- Next pending phase: `11_admin_login`.
This commit is contained in:
2026-10-18 05:44:13 -04:00
parent fefbeb8227
commit a08e693698
55 changed files with 1259 additions and 299 deletions
@@ -0,0 +1,104 @@
# Phase 10 — Dev dials the fleet directly (no more SSH tunnels)
**Source:** user request (2026-10) — "I don't want this separation of architectures. My laptop can
talk to these hosts directly via local network or vpn already. Just have dev work like prod."
**Story:** n/a
**Context:** `AGENTS.md` (dev workflow: `scripts/dev-tunnels.sh`, `DEV_TUNNELS`/`DEV_FORWARDS`),
`deploy/install-agent.sh`, `deploy/nut-sudoers`, `scripts/dev-server.sh`,
`scripts/capture-fixtures.sh`, `tests/test_live_fleet.py`, `.env.dev.example`, `.env.example`,
`README.md`
## Objective
Dev becomes the prod topology: each host's agent runs as a **systemd service** (installed by
`ops` through a documented minimal sudoers grant), bound to the address the dashboard dials,
token in the URL userinfo; driveripper's upsd ACLs the host and laptop IP ranges on port 3051;
`.env.dev` points at the real hosts with no `DEV_TUNNELS`/`DEV_FORWARDS`; `scripts/dev-tunnels.sh`
is retired. The dashboard's Python is untouched — this phase changes deployment, config, scripts
and docs, and proves parity with the live suite plus browser screenshots.
## Decisions (user, 2026-10)
- The agent is a **systemd service, installed by the `ops` user** (not root). The repo documents
the **simplest possible working sudoers addition** in `deploy/agent-sudoers`, and the user adds
it to each host.
- The upsd ACL allows the **host's IP range and the laptop's IP range** — both real ranges,
derived at install time and commented in `.env.dev` so they can be re-derived when the laptop's
IP changes.
## Precondition (user action, before task 02)
The sudoers line needs root, and the command-scoped sudo on driveripper does not include
`visudo`. The user adds the one line from `deploy/agent-sudoers` on each host. If it is missing
when task 02 runs, that task must **stop cleanly and print the exact instructions** — never loop,
never wait, never attempt to edit sudoers itself.
## Dependencies
- `05_ups_hero_battery_glyph` (complete) — the last UI phase; its pages are what this phase
re-verifies against live data after the transport change.
- `06_system_page_gpu_verdicts`, `07_llm_monitor_style_pages`, `08_events_paging_and_copy`,
`09_disks_smartctl` (todo) — independent: this phase changes transport and ops, not payloads or
page behaviour, and they do not depend on the transport.
## Tasks
1. `01_sudoers_grant_and_installer_hint.md` — `deploy/agent-sudoers` (the minimal working grant
+ install instructions, in the `nut-sudoers` house style); `install-agent.sh` run without
root prints the exact line to add.
2. `02_install_agents_on_fleet.md` — on all three hosts: kill the ad-hoc agent, stage the
installer, install the systemd service bound to the dialed address, capture the printed
tokens, verify from the laptop.
3. `03_upsd_acl_for_laptop.md` — driveripper: append ACL lines for the host range and the laptop
range to `upsd.conf` (read-first, `cmp`-after, never blind-write), restart `nut-server`, verify
the NUT wire from the laptop.
4. `04_env_dev_direct_dial.md` — stop the tunnels; rewrite the local `.env.dev` to direct targets
(userinfo tokens, `NUT_HOST`/`NUT_PORT=3051`); restart the dev server; sources ok.
5. `05_retire_tunnels_update_scripts.md` — delete `scripts/dev-tunnels.sh`; rewrite
`.env.dev.example` direct-shaped; fix `capture-fixtures.sh`, the live suite's docstring and
skip messages, `ups_contract.py`, the `alerts.py` docstring, `test_config.py`, `.env.example`'s
NUT note, and the dev-workflow sections of `README.md`/`AGENTS.md`.
6. `06_verify_direct_dial_live.md` — full live verification: `/api/health` ok over direct dial,
`uv run pytest -m live --live` green, browser screenshots of `/` + `/system` + `/ups` + `/llm`
on live data, no tunnel port needed on the laptop.
## Testing & Quality
- The Python changes in this phase are test-file strings and one docstring — no new logic. The
full suite must stay 100% green and overall coverage must not drop (gate: `--cov-fail-under=94`).
- `bash -n` on `deploy/install-agent.sh` and `scripts/capture-fixtures.sh` after their edits.
- The real proof of this phase is the live path: `uv run pytest -m live --live` against the
directly-dialed fleet (the same production code paths over the prod-shaped transport) plus a
browser screenshot per `AGENTS.md` rule 10.
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in
the project, not just the phase's — pre-existing failures included) and the coverage gate in
`.agents/validate.sh` holds.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands
after this phase — fixing every inconsistency found, **including pre-existing ones not caused
by the phase** (including any remaining mention of `dev-tunnels.sh`, `DEV_TUNNELS`,
`DEV_FORWARDS`, `9121`/`9122`/`9123` or `3494`).
- Task 05 updates `README.md`/`AGENTS.md` immediately for the user-visible dev-workflow change.
- `PLAN.md` is locked and must not be edited: its Data-Model note ("forwarded to
`127.0.0.1:3494` in dev") goes stale after this phase — flag it in the final pass's report to
the user, and leave the file untouched.
## Completion Criteria
- [ ] `systemctl status dashboards-agent` is active on all three hosts, each bound to the address
the dashboard dials, answering `/health` (unauthenticated) and `/snapshot` (bearer) from the
laptop
- [ ] driveripper's upsd answers `LIST UPS` from the laptop on 3051 — not via the host's loopback
- [ ] `git grep -n "dev-tunnels\|DEV_TUNNELS\|DEV_FORWARDS\|9121\|9122\|9123\|3494"` matches
nothing outside `.agents/phases/complete/`
- [ ] `./scripts/dev-server.sh status` shows every source `ok` over direct dial
- [ ] `uv run pytest -m live --live` (with `.env.dev` sourced) is green
- [ ] `data/logs/` holds `phase10-*.png` screenshots of `/`, `/system`, `/ups`, `/llm` on live
data over direct dial
- [ ] full test suite **100% passing**, coverage gate (≥94%) holds, `ruff` + `pyright` clean
- [ ] `README.md` and `AGENTS.md` describe the direct-dial dev workflow and mention no tunnels
(verified in the final pass)
- [ ] no behavior change in completed phases: the four pages render the same payloads as before
the transport change
@@ -0,0 +1,59 @@
# Task 01 — The agent sudoers grant and the installer's hint
**Phase:** `10_dev_direct_dial` · **Story:** n/a
## Objective
A user with the command-scoped `ops` sudo (no root) can install `deploy/install-agent.sh`
because the repo documents the single sudoers line that makes it work, and running the
installer without root tells you that exact line instead of the generic "run as root".
## Work
1. `deploy/agent-sudoers` (new) — house style: follow `deploy/nut-sudoers` (header with the
`visudo -cf` check-first install steps, a "WHY THESE ARE NEEDED" block, scope notes, the
grant, a "deliberately NOT granted" block). Content:
- **Why:** the agent is read-only by construction and every non-`/health` request needs the
bearer token, so the only privileged operation is installing its files and unit. The grant
is the whole script path, run as root, with no argument restriction — the flags
(`--bind`, `--port`, `--status`, `--uninstall`, `--rotate-token`) are non-sensitive, and
the documented flow never puts a token on argv (the installer generates one and prints it
once).
- **Canonical staging path:** the host has no repo checkout, so the host gets
`deploy/` + `agent/` staged to `~/agent-install/` (task 02 does the staging). The grant
line is therefore stable:
```
ops ALL=(root) NOPASSWD: /home/ops/agent-install/deploy/install-agent.sh
```
- **Install steps** (per host, the same two-stage check as `nut-sudoers`):
`sudo visudo -cf deploy/agent-sudoers` (adapt the path first), then install to
`/etc/sudoers.d/dashboards-agent` with `install -m 0440 -o root -g root`, and
`sudo visudo -cf` on the installed copy.
- **Deliberately NOT granted:** `visudo`/`vim`/`sh` (no self-amendment of the grant),
`systemctl *` other than what the script itself already needs as root, `cat` of
`/etc/dashboards-agent.token` (the token stays root-readable; the installer prints it
once), and the NUT instant-command clients.
2. `deploy/install-agent.sh` — replace the `need_root` failure message for the non-root case:
when `id -u != 0`, print (a) the exact NOPASSWD line for the script's **resolved absolute
path** (so a checkout at a different path still works), (b) the two install steps from
`deploy/agent-sudoers`, and (c) a pointer to `deploy/agent-sudoers`. Keep the exit code
(1) and the behaviour when already root unchanged. `bash -n` must stay clean.
## Testing & Quality
- `bash -n deploy/install-agent.sh` clean.
- Run the script **as the current (non-root) user** locally: `bash deploy/install-agent.sh
--bind 127.0.0.1` must exit 1 and print the hint with this checkout's absolute path; the
printed line must match the file's canonical line apart from the path (diff them in
`data/logs/NOTES-sudoers.md`).
- No Python changes this task; the full suite must stay green.
## Completion Criteria
- [ ] `deploy/agent-sudoers` exists, passes `visudo -cf` syntax locally, and names the canonical
staging path and the exact grant line
- [ ] non-root `bash deploy/install-agent.sh --bind 127.0.0.1` prints the exact line to add and
exits 1
- [ ] root behaviour unchanged (the script still refuses nothing new; `need_root` logic intact)
- [ ] `bash -n` clean on the script; `uv run pytest` 100% green; `ruff` + `pyright` clean
- [ ] README.md/AGENTS.md unchanged yet (task 05 is where the docs change)
@@ -0,0 +1,66 @@
# Task 02 — Install the agents as systemd services on the fleet
**Phase:** `10_dev_direct_dial` · **Story:** n/a
**Precondition (user action):** the one line from `deploy/agent-sudoers` is installed on each
host (root-only; the scoped sudo does not include `visudo`). If it is missing on a host, this
task must stop cleanly for that host and leave the exact instructions in
`data/logs/NOTES-agent-install.md` — never loop, never wait, never edit sudoers.
## Objective
Each of the three hosts (`driveripper`, `deskwork`, `gateway`) runs `dashboards-agent` as an
active systemd service — the prod shape — bound to the address the dashboard will dial, with
its bearer token captured for `.env.dev`. The old ad-hoc tunnel agent is gone.
## Work
1. Once, on the laptop: `./scripts/dev-tunnels.sh stop` (kills the local ssh legs; the remote
ad-hoc agents are orphans and are killed in step 2).
2. Per host (`ssh driveripper-ops | deskwork-ops | gateway-ops`):
- `ssh <host> "pkill -f 'host_agent.py --port 9120' || true"` — the orphaned ad-hoc agent
from the old dev flow (it runs as `ops`, so `ops` can kill it). Leave the
`~/lab-agent/` files on disk; they are simply no longer used.
- Stage the installer: `ssh <host> "mkdir -p ~/agent-install"` then
`rsync -a --delete deploy/ agent/ <host>:~/agent-install/`.
- Check the grant: `ssh <host> "sudo -n ~/agent-install/deploy/install-agent.sh --status"`.
On a permission failure: append to `data/logs/NOTES-agent-install.md` the host, the exact
line from `deploy/agent-sudoers` (with this host's staging path), and the two `visudo`
install steps; mark the host **BLOCKED**, continue with the next host, and end the task
with the blocker visible in the summary. Do not retry or wait.
- Install: `ssh <host> "sudo ~/agent-install/deploy/install-agent.sh --bind <addr> --port 9120"`.
- `<addr>` is the address the dashboard will dial. Derive it: the host's name from
`.env.dev` (the `*.reeselink.com` names used by the engines, or the LAN name the user
uses), resolved on the laptop (`getent hosts <name>`), then confirmed present on the
host (`ssh <host> "ip -o addr"`). If resolution is ambiguous, use `0.0.0.0` — the
installer requires a token off-loopback, which the flow always provides.
- The installer prints the new token exactly once on first install. Record it for task 04
(`data/logs/NOTES-agent-install.md` may hold `host:port + unit state` only — **no
tokens** in NOTES; the tokens go into `.env.dev` in task 04, which is `0600` and
gitignored, the same place `NUT_PASSWORD` already lives).
- Verify **from the laptop** (a host-side check could pass via loopback):
`curl -sf http://<host>:9120/health` → `{"ok": true}`, and
`curl -sf -H "Authorization: Bearer <token>" http://<host>:9120/snapshot | head -c 200`
shows the `cpu` object.
- `ssh <host> "systemctl status dashboards-agent --no-pager" | head -5` → `active (running)`.
3. If a firewall on any host blocks 9120 from the laptop (the `curl` in step 2 fails while
`systemctl status` is active), record it in `data/logs/NOTES-agent-install.md` with the host
and the port, and mark the host BLOCKED-with-details — the user's scoped sudo set may not
cover firewall edits on every host, and this task does not guess.
## Testing & Quality
- No Python changes; `uv run pytest` must stay 100% green (this is an ops task — its "tests"
are the verifications above).
- `data/logs/NOTES-agent-install.md` records per host: bind address, unit state, verification
outcome, and — for any BLOCKED host — the exact user instructions. No secrets in the file.
## Completion Criteria
- [ ] all three hosts: `dashboards-agent` active, verified from the laptop (or a host is
BLOCKED with the exact user instructions in `data/logs/NOTES-agent-install.md`)
- [ ] the old ad-hoc agent is dead on each host (the pkill in step 2 ran; no `9120` loopback
listener remains)
- [ ] tokens captured for task 04 (in the NOTES file only as host:port — the values go straight
into `.env.dev` there)
- [ ] `uv run pytest` 100% green; `ruff` + `pyright` clean
@@ -0,0 +1,54 @@
# Task 03 — upsd ACL for the host and laptop IP ranges
**Phase:** `10_dev_direct_dial` · **Story:** n/a
## Objective
driveripper's upsd (port 3051) accepts the NUT wire from the laptop over the network, using
the user-decided ranges — the host's IP range and the laptop's IP range — so the UPS source
can dial `NUT_HOST=<driveripper-addr> NUT_PORT=3051` directly. No `upsd.users` account is
touched, so `upsmon`'s orderly power-down is preserved.
## Work
1. Read first (the `NUT_READ` sudo grant on driveripper covers `cat /etc/ups/*`):
`ssh driveripper-ops "sudo cat /etc/ups/upsd.conf" > data/logs/upsd.conf.before`.
Confirm the file's current ACL lines (NUT 2.8 refuses non-local connections by default, so
there are likely none).
2. Derive the two ranges:
- **Laptop address as seen from driveripper:** `ssh driveripper-ops "ss -tn state
established '( sport = :22 )'"` — the source IP of this ssh session.
- **Host range:** driveripper's LAN subnet (`ssh driveripper-ops "ip -o addr"` — the
interface prefix, e.g. a `/24`).
3. Build the new file as `upsd.conf.before` **plus** exactly two appended NUT 2.8 ACL lines
(`ACL <ip-or-net> <netmask>`; a single host is mask `255.255.255.255`), write it back with
the granted `tee`, then prove the diff is exactly those two lines — the same read-first /
`cmp`-after care `AGENTS.md` mandates for `upsd.users` (never a blind write):
```
cat data/logs/upsd.conf.new | ssh driveripper-ops "sudo tee /etc/ups/upsd.conf"
ssh driveripper-ops "sudo cat /etc/ups/upsd.conf" | diff - data/logs/upsd.conf.before
```
4. `ssh driveripper-ops "sudo systemctl restart nut-server"` (the `NUT_WRITE` grant). `upsmon`
re-authenticates from `upsd.users`, which was not modified.
5. Firewall: `ssh driveripper-ops "sudo firewall-cmd --list-ports"` — if a firewall is active
and 3051 is not reachable from the laptop, add a rule limited to the laptop address (the
scoped sudo includes `firewall-cmd *` "only if upsd must serve the LAN"); record what was
added in `data/logs/NOTES-upsd-acl.md`. If no firewall is active, record that.
6. Verify **from the laptop** (a host-side check passes via loopback and proves nothing):
`printf 'LIST UPS\n' | nc -w 5 <driveripper-addr> 3051` → `BEGIN LIST UPS`. Task 04's
`/api/health` is the app-level proof.
## Testing & Quality
- No Python changes; `uv run pytest` must stay 100% green.
- `data/logs/NOTES-upsd-acl.md` records the two ACL lines, the diff output (must show exactly
the two added lines), the firewall state, and the laptop-side verification result.
## Completion Criteria
- [ ] `diff` between `upsd.conf.before` and the live file shows exactly the two ACL lines
- [ ] `nut-server` restarted cleanly (`systemctl status nut-server` active)
- [ ] from the laptop, `printf 'LIST UPS\n' | nc -w 5 <driveripper-addr> 3051` answers
`BEGIN LIST UPS` (with both UPS names following, or at least the BEGIN marker)
- [ ] no `upsd.users` modification (the diff is limited to `upsd.conf`)
- [ ] `uv run pytest` 100% green; `ruff` + `pyright` clean
@@ -0,0 +1,44 @@
# Task 04 — `.env.dev` goes direct
**Phase:** `10_dev_direct_dial` · **Story:** n/a
## Objective
The dev configuration dials the fleet the prod way: real host addresses with bearer tokens in
URL userinfo, upsd directly on 3051, and no `DEV_TUNNELS`/`DEV_FORWARDS` — with the dev server
showing every source healthy over the new transport.
## Work
1. `./scripts/dev-tunnels.sh stop` (idempotent; task 02 may have done it already).
2. Edit the local `.env.dev` (keep its `0600` mode; it is gitignored):
- delete the `DEV_TUNNELS` and `DEV_FORWARDS` lines
- `HOST_TARGETS="driveripper=http://<token>@<addr>:9120 deskwork=http://<token>@<addr>:9120
gateway=http://<token>@<addr>:9120"` — the addresses exactly as task 02 verified them,
the tokens task 02 captured.
- `NUT_HOST=<driveripper-addr>`, `NUT_PORT=3051`; keep `NUT_USER` / `NUT_PASSWORD` /
`NUT_ALLOW_ACTIONS` unchanged.
- `GUFO_BASE_URL` / `VLLM_BASE_URL` stay (already direct); `DASH_PORT` stays.
- add a short comment: where the upsd ACL lines live (`/etc/ups/upsd.conf` on
driveripper) and that a changed laptop IP means re-adding one ACL line (task 03's
method).
3. `./scripts/dev-server.sh restart`, then `./scripts/dev-server.sh status` — every source
`ok`. A host that was BLOCKED in task 02 may still be down; that is expected and must be
recorded, not "fixed" with a tunnel.
4. Watch the transition: a host that was tunnel-served should log an unreachable/ok (or
stale/ok) transition in `/api/events` — the poller's normal bookkeeping. Confirm the
transitions are the only new events and no source is stuck `DOWN`.
## Testing & Quality
- `uv run pytest` 100% green (no Python changes this task).
- `.env.dev` keeps mode `0600` and contains no token in any file git tracks.
## Completion Criteria
- [ ] `.env.dev` has no `DEV_TUNNELS`/`DEV_FORWARDS` and every target is a real address with
userinfo token (hosts) or direct `host:3051` (NUT)
- [ ] `./scripts/dev-server.sh status` shows every reachable source `ok` over direct dial
- [ ] `curl -s localhost:8090/api/system/snapshot` shows live data for every installed host
(a BLOCKED host is absent or stale — recorded, not hidden)
- [ ] the dev server shows no `DOWN` source that was healthy before the switchover
@@ -0,0 +1,69 @@
# Task 05 — Retire the tunnel machinery and update scripts and docs
**Phase:** `10_dev_direct_dial` · **Story:** n/a
## Objective
Everything that exists to serve the old architecture goes away or gets rewritten: the tunnel
script is deleted, the example env file is direct-shaped, and no tracked file mentions the
tunnel ports or variables any more.
## Work
1. `git rm scripts/dev-tunnels.sh` — the user decided against the separation; git history
keeps the script if it is ever needed.
2. `scripts/capture-fixtures.sh` — drop the "needs the tunnels up" line; the hard-coded
`HOSTS=(driveripper:9121 deskwork:9122 gateway:9123)` becomes a parse of `HOST_TARGETS`
from `.env.dev` (source it the way `dev-server.sh` does: `set -a; . .env.dev; set +a`,
then split `name=url` pairs and use the URL as-is, token userinfo and all — the app's own
`config.parse_targets` shows the shape). The NUT line already uses `$NUT_HOST`.
`bash -n` must stay clean.
3. `tests/test_live_fleet.py` — module docstring: replace the `./scripts/dev-tunnels.sh
start` line of the dev loop with `set -a; . .env.dev; set +a` (direct dial); both skip
messages become `pytest.skip("HOST_TARGETS is unset")`.
4. `tests/ups_contract.py` — the message that mentions `DEV_FORWARDS` (around L89): the UPS
hero row is contracted against the real drivers over direct dial; reword, drop the
forward reference.
5. `src/dashboards/alerts.py` — the docstring example `http://h:9121/snapshot` (around L138):
update to the direct-dial shape (`http://<host>:9120/snapshot`). Docstring only.
6. `tests/test_config.py` — the `HOST_TARGETS` monkeypatch `deskwork=http://127.0.0.1:9122`
(around L100): cosmetic, update to the direct shape (e.g.
`deskwork=http://tok@deskwork.example.net:9120`); the test stays hermetic.
7. `.env.example` — the NUT section's "reach it with an ssh forward or a tailnet address, or
bind a LAN address only if a firewall rule limits it": add the now-default third option —
ACL the dashboard's source range in `upsd.conf` (NUT refuses non-local connections by
default).
8. `.env.dev.example` — full rewrite, direct-shaped: the agent installed per host via
`deploy/agent-sudoers` + `deploy/install-agent.sh` (bound to the dialed address, token in
the URL userinfo), `NUT_HOST`/`NUT_PORT` pointing at the real upsd (keep the `ss -ltn` /
`nc 'LIST UPS'` port-discovery note — still the right first move), the upsd-ACL note, no
`DEV_TUNNELS`/`DEV_FORWARDS`, RFC 2606 placeholder hostnames (the file's existing
convention), and the "never commit this file" warning.
9. `README.md` — the dev section (around L40–49 and L275): replace the tunnel lines with the
direct-dial flow — the agent install (the `deploy/agent-sudoers` grant, the staged
installer, the token into `HOST_TARGETS` userinfo), the upsd ACL, `.env.dev`,
`./scripts/dev-server.sh restart`. State plainly that dev is the prod topology.
10. `AGENTS.md` — the fleet + Commands sections: the agents are systemd services installed by
`ops` via the `deploy/agent-sudoers` grant (bound to the dialed address, token in
userinfo); upsd serves the LAN for the ACLed ranges; the `dev-tunnels.sh` lines out of the
Commands block; "Nothing listens on the hosts in dev" is the opposite of the truth now and
must go. Keep the port-3051 discovery note (it is still why `3493` looks dead to a
default client).
## Testing & Quality
- `uv run pytest` 100% green (the edits are strings/docstrings; the suite must catch any
regression), `ruff` + `pyright` clean, `bash -n scripts/capture-fixtures.sh` clean.
- `git grep -n "dev-tunnels\|DEV_TUNNELS\|DEV_FORWARDS\|9121\|9122\|9123\|3494" --` must
match nothing outside `.agents/phases/complete/` (and this phase's own todo files, which
describe the change).
## Completion Criteria
- [ ] `scripts/dev-tunnels.sh` is deleted from the working tree
- [ ] `git grep` for the tunnel names/ports is clean (per Testing & Quality)
- [ ] `.env.dev.example` is direct-shaped and passes the same "copy and edit" workflow as
before
- [ ] `README.md` and `AGENTS.md` describe the direct-dial dev workflow with no tunnel
references (the phase's final pass re-checks both project-wide)
- [ ] full test suite 100% passing; `ruff` + `pyright` clean
@@ -0,0 +1,52 @@
# Task 06 — Verify the direct-dial fleet end to end
**Phase:** `10_dev_direct_dial` · **Story:** n/a
## Objective
Prove the claim this phase exists for — dev works like prod: every source healthy over the
direct transport, the live suite green against the real fleet, and the four pages rendering
live data in a browser, with no tunnel port existing on the laptop.
## Work
1. `./scripts/dev-server.sh restart`, then `curl -s localhost:8090/api/health` — every source
`ok` (a host BLOCKED in task 02 is the only acceptable `DOWN`, and it must be the one
recorded in `data/logs/NOTES-agent-install.md`).
2. Prove the laptop needs no tunnel port:
`ss -ltn | grep -E ':(9121|9122|9123|3494)\s'` → empty (the old tunnel ports are gone),
while the host sources in `/api/health` are still `ok`.
3. `set -a; . .env.dev; set +a; uv run pytest -m live --live` — green (or the normal
per-source skips; a source that is configured and reachable must not skip).
4. Browser verification (`AGENTS.md` rule 10) — screenshots to `data/logs/` as
`phase10-*.png`, all against live data over direct dial:
- `/` — the fleet table shows all three hosts with live numbers (any BLOCKED host dimmed
`n/a`, as designed), alerts consistent with the source states
- `/system` — three live host cards (CPU/MEM/TEMP/DISK/GPU/NET heroes populated where the
hardware reports it; gateway's absent GPU stays an uncoloured `n/a`)
- `/ups` — both real UPS cards, live charge/load/runtime, **no `SIMULATED` badge**
- `/llm` — the live engine(s) with throughput and latency cells
5. `data/logs/NOTES-verify.md` — what was verified, which hosts are BLOCKED (if any) with a
pointer to the task-02 notes, and the `PLAN.md` staleness flag: its Data-Model note
("forwarded to `127.0.0.1:3494` in dev", around L94) no longer describes reality —
**report it to the user; do not edit `PLAN.md`** (locked).
6. The full gate: `bash .agents/validate.sh`.
## Testing & Quality
- The live suite is read-only by policy — nothing in this task sends an instant command,
restarts a service, or writes to an upstream.
- `uv run pytest` (hermetic) 100% green; coverage gate (≥94%) holds; `ruff` + `pyright`
clean.
## Completion Criteria
- [ ] `/api/health`: every reachable source `ok` over direct dial; no `DOWN` source that was
healthy before the phase
- [ ] no tunnel port (9121/9122/9123/3494) listening on the laptop
- [ ] `uv run pytest -m live --live` green against the directly-dialed fleet
- [ ] `data/logs/phase10-*.png` exists for `/`, `/system`, `/ups`, `/llm`, showing live data
- [ ] `data/logs/NOTES-verify.md` written, including the `PLAN.md` staleness flag for the user
- [ ] `bash .agents/validate.sh` passes
- [ ] no behavior change in completed phases (same payloads, same pages — only the transport
underneath changed)