**Phase 07 final verification pass — all tasks already in `complete/`; every criterion re-verified live.**
- **Old slot table gone**: no `#slots-card`/`#slots-table`/`renderSlots` in HTML/JS/CSS (only the new grid's `.slots-empty` *class*); pinned by `test_the_broken_slot_table_is_gone_from_html_js_and_css`.
- **`tests/fixtures/slots_gufo.json`**: present; live `GET ${GUFO_BASE_URL}/slots` this pass matches it key-for-key (row + `next_token[0]`), so the capture is reproducible; drift proven to fail (mutation tests + live `test_gufo_slots_answer_the_pinned_shape`, 9 live tests pass).
- **Aggregate block**: live page shows `2 of 2 engines`, running/waiting/prompt/decode/total-tokens straight from `fleet_totals()`, dot `ok`, no verdict colour; on a throwaway single-engine instance (:8091, private temp DB) `#fleet` is `hidden`, one full-width strip card + one panel, zero banners → "one clean half".
- **Strip + slot grid on live data**: 2 `.card.stat` (KV bars `bar ok` 13 %/0 %), gufo grid rendered real cards incl. a busy slot (`#2189`, 67849 prompt tok, 99 % cache share, `processing`), vLLM note-only with no grid; no `NaN`/`undefined` anywhere. Screenshots: `data/logs/llm-phase07-final-live-{two-engines,single-engine}.png`, `ups-charts-aria-labels.png`.
- **Defect found in earlier work and fixed**: `/ups`' four `LineChart`s shipped no `ariaLabel`, violating AGENTS.md's "every chart names itself for assistive tech" (`/system`+`/llm` were pinned, `/ups` wasn't) → added labels to `page-ups.js` and `test_every_ups_chart_names_itself_for_assistive_tech`; browser-verified `role="img"` + label on all four.
- **Gate**: `bash .agents/validate.sh` → **exit 0**; `uv run pytest --cov --cov-fail-under=94` → **604 passed, 9 skipped, coverage 97.91 %**; `uv run ruff check src tests agent` → All checks passed; `uv run pyright` → **0 errors**; `node --check` every page script ok; `uv run pytest -m live --live` → **9 passed**; `uv pip list --outdated` empty.
- **Docs**: README + AGENTS.md reviewed end-to-end; fixed test count (582→604), `/llm` source column + both architecture diagrams now name gufo `/slots`, AGENTS.md agent line gained `GET /schema` + status page, ariaLabel convention now covers all three chart pages, README Testing block now lists `bash .agents/validate.sh`. Env-var table, agent/install flags, Containerfile facts, `/api/*` shapes, range chips, remote URL all verified accurate — no other changes needed.
- **Live-infra**: no fleet/host/config change; the temp :8091 instance, its temp DB and the fake processes are removed; dev server restarted and `/api/health` `ok` (all 6 sources ok). One thing I deliberately did **not** touch: the gitignored local `.env.dev` still carries a comment claiming the NUT `[dashboards]` account has only battery-test `instcmds`, whereas AGENTS.md/README record `instcmds = all` (verified on hardware 2026-10-17) — operator-owned file, flag for the operator to reword.
- **Next pending phase**: `08_events_paging_and_copy`.
220 lines
9.4 KiB
Bash
Executable File
220 lines
9.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Re-record ground-truth captures into tests/fixtures/.
|
|
#
|
|
# ./scripts/capture-fixtures.sh # needs the tunnels up: scripts/dev-tunnels.sh start
|
|
# ./scripts/capture-fixtures.sh --with-llm # also scrape the live engines' /metrics
|
|
# # (gufo /slots is captured without the flag)
|
|
#
|
|
# Fixtures are verbatim, not hand-written: a fixture you typed is a test of your memory.
|
|
# When a host's kernel or an engine's metric names change, re-run this and read the diff.
|
|
set -uo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
FIX="$ROOT/tests/fixtures"
|
|
mkdir -p "$FIX"
|
|
|
|
HOSTS=(driveripper:9121 deskwork:9122 gateway:9123)
|
|
# Engine URLs come from the same env the app uses, so this script carries no deployment
|
|
# detail of its own — and never records a tokenised URL into a fixture by accident.
|
|
if [ -f "$ROOT/.env.dev" ]; then
|
|
set -a; . "$ROOT/.env.dev"; set +a
|
|
fi
|
|
ENGINES=()
|
|
[ -n "${GUFO_BASE_URL:-}" ] && ENGINES+=("gufo:${GUFO_BASE_URL%/}/metrics")
|
|
[ -n "${VLLM_BASE_URL:-}" ] && ENGINES+=("vllm:${VLLM_BASE_URL%/}/metrics")
|
|
|
|
failed=0
|
|
|
|
for entry in "${HOSTS[@]}"; do
|
|
name=${entry%%:*}; port=${entry##*:}
|
|
url="http://127.0.0.1:${port}/snapshot"
|
|
if curl -fsS --max-time 8 "$url" -o "$FIX/agent_${name}.json"; then
|
|
echo " ✓ agent_${name}.json ($(wc -c <"$FIX/agent_${name}.json") bytes)"
|
|
else
|
|
echo " ✗ agent_${name}.json — no agent on $url (scripts/dev-tunnels.sh start?)"
|
|
failed=1
|
|
fi
|
|
done
|
|
|
|
# --- UPSes (NUT upsd) ---------------------------------------------------------
|
|
# The two real UPSes disagree in ways a hand-written VAR table cannot: the APC's
|
|
# `battery.date` is the factory placeholder `2001/09/25` (its `battery.mfr.date` is the real
|
|
# one), the CyberPower answers `battery.mfr.date: CPS`, and neither exports
|
|
# `battery.temperature` or `battery.charger.status`. `tests/test_ups_contract.py` pins the
|
|
# UPS hero row against these replies, so they are recorded from the wire, not typed from
|
|
# memory (AGENTS.md rule 7). Skipped when NUT_HOST is unset, so a laptop with no upsd
|
|
# reachable still re-records the rest.
|
|
if [ -n "${NUT_HOST:-}" ]; then
|
|
if python3 - "$FIX" "$NUT_HOST" "${NUT_PORT:-3493}" <<'PY'
|
|
import pathlib
|
|
import socket
|
|
import sys
|
|
|
|
fix, host, port = pathlib.Path(sys.argv[1]), sys.argv[2], int(sys.argv[3])
|
|
HEADER = ("# Verbatim upsd reply (LIST UPS / LIST VAR / LIST CMD), recorded by\n"
|
|
"# ./scripts/capture-fixtures.sh — one `VAR`/`CMD` line per protocol line.\n"
|
|
"# Volatile values (ups.load, battery.runtime, the voltages) move every poll,\n"
|
|
"# so a diff here on re-capture is expected; the contract asserts shape, units\n"
|
|
"# and which variables the drivers omit, never those numbers.")
|
|
|
|
|
|
class Upsd:
|
|
"""Raw socket on purpose: the fixture is the wire, not a parse of it."""
|
|
|
|
def __init__(self, host: str, port: int) -> None:
|
|
self.handle = socket.create_connection((host, port), timeout=8).makefile("rwb")
|
|
|
|
def line(self) -> str:
|
|
raw = self.handle.readline()
|
|
if not raw:
|
|
raise EOFError("upsd closed the connection")
|
|
return raw.decode(errors="replace").rstrip("\r\n")
|
|
|
|
def ask(self, command: str) -> list[str]:
|
|
self.handle.write((command + "\n").encode())
|
|
self.handle.flush()
|
|
first = self.line()
|
|
if not first.startswith("BEGIN LIST"):
|
|
raise RuntimeError(f"{command!r} -> {first!r}")
|
|
rows: list[str] = []
|
|
while True:
|
|
row = self.line()
|
|
if row.startswith("END LIST"):
|
|
return rows
|
|
rows.append(row)
|
|
|
|
|
|
upsd = Upsd(host, port)
|
|
for row in upsd.ask("LIST UPS"):
|
|
if not row.startswith("UPS"):
|
|
continue
|
|
name = row.split()[1].strip('"')
|
|
body = [HEADER, row,
|
|
*upsd.ask(f'LIST VAR "{name}"'),
|
|
*upsd.ask(f'LIST CMD "{name}"')]
|
|
path = fix / f"nut_{name}.vars"
|
|
path.write_text("\n".join(body) + "\n")
|
|
print(f" ✓ nut_{name}.vars ({len(body) - 1} protocol lines)")
|
|
PY
|
|
then
|
|
: # per-UPS lines above
|
|
else
|
|
echo " ✗ nut_*.vars — upsd at $NUT_HOST:${NUT_PORT:-3493} unreachable (scripts/dev-tunnels.sh start?)"
|
|
failed=1
|
|
fi
|
|
else
|
|
echo " (skipped UPS captures — NUT_HOST unset; see .env.dev.example)"
|
|
fi
|
|
|
|
# --- gufo /slots (per-slot rows) ---------------------------------------------
|
|
# `tests/slots_contract.py` pins the shape of the `/llm` slot grid (task 04) against
|
|
# `tests/fixtures/slots_gufo.json`, so this capture is NOT behind `--with-llm`: it is one
|
|
# small JSON document and the hermetic contract test needs it on disk. `/v1/slots` is the
|
|
# documented alias, tried second — the same order `collectors.fetch_engine` and
|
|
# `llm_monitor`'s passthrough use, so a server that only exposes the alias is still
|
|
# capturable. Skipped when GUFO_BASE_URL is unset, like the UPS captures. An idle engine
|
|
# still answers one row per parallel slot; that idle shape is what the grid must handle.
|
|
if [ -n "${GUFO_BASE_URL:-}" ]; then
|
|
gufo_base="${GUFO_BASE_URL%/}"
|
|
slots_tmp="$FIX/.slots_gufo.json.tmp"
|
|
if curl -fsS --max-time 8 "${gufo_base}/slots" -o "$slots_tmp" ||
|
|
curl -fsS --max-time 8 "${gufo_base}/v1/slots" -o "$slots_tmp"; then
|
|
mv "$slots_tmp" "$FIX/slots_gufo.json"
|
|
echo " ✓ slots_gufo.json ($(wc -c <"$FIX/slots_gufo.json") bytes)"
|
|
else
|
|
rm -f "$slots_tmp" # never overwrite a good fixture with a dead engine's empty file
|
|
echo " ✗ slots_gufo.json — gufo answered neither /slots nor /v1/slots at $gufo_base"
|
|
failed=1
|
|
fi
|
|
else
|
|
echo " (skipped gufo /slots capture — GUFO_BASE_URL unset; see .env.dev.example)"
|
|
fi
|
|
|
|
if [ "${1:-}" = "--with-llm" ]; then
|
|
if [ ${#ENGINES[@]} -eq 0 ]; then
|
|
echo " ! no GUFO_BASE_URL / VLLM_BASE_URL set (see .env.dev) — nothing to capture"
|
|
failed=1
|
|
fi
|
|
for entry in "${ENGINES[@]}"; do
|
|
name=${entry%%:*}; url=${entry#*:}
|
|
if curl -fsS --max-time 20 "$url" -o "$FIX/metrics_${name}.txt"; then
|
|
echo " ✓ metrics_${name}.txt ($(wc -l <"$FIX/metrics_${name}.txt") lines)"
|
|
else
|
|
echo " ✗ metrics_${name}.txt — $url unreachable (VPN? engine down?)"
|
|
failed=1
|
|
fi
|
|
done
|
|
else
|
|
echo " (skipped engine /metrics captures — pass --with-llm when the engines are reachable)"
|
|
fi
|
|
|
|
# Secrets never belong in a fixture: engines can expose /metrics behind a token in the URL,
|
|
# and a hostname you use at home is a hostname in a public repo.
|
|
if grep -rlE "Authorization|Bearer |://[^/]*:[^/@]*@" "$FIX" >/dev/null 2>&1; then
|
|
echo " ! fixture contains what looks like a credential — scrub it before committing"
|
|
failed=1
|
|
fi
|
|
|
|
# Three things leak out of a real capture that nobody notices: LUKS/partition UUIDs (which
|
|
# identify *your* disks, and appear in `device` strings as /dev/mapper/luks-<uuid>), the
|
|
# FQDN a host thinks it has, and a UPS's `ups.serial`/`device.serial` (which identifies the
|
|
# physical unit, and is echoed back in the card's variable dump). They are replaced
|
|
# deterministically so re-capturing does not churn the fixture, and the tests keep the same
|
|
# shapes to assert against. A fourth belongs only to the /slots capture: a busy slot's
|
|
# `prompt` field is *the user's in-flight request text* — personal data the slot grid never
|
|
# renders — so any non-empty prompt value is blanked, keeping the key (the shape) without
|
|
# the content.
|
|
python3 - "$FIX" <<'PY'
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
uuid_re = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
|
|
SYNTHETIC = "00000000-0000-4000-8000-" # already-scrubbed markers: re-running must not churn
|
|
fix = pathlib.Path(sys.argv[1])
|
|
changed = []
|
|
# upsd quotes a field only when it has to, so both `VAR "apc" "ups.serial" "x"` and
|
|
# `VAR apc ups.serial "x"` are real wire shapes; the value is always quoted.
|
|
serial_re = re.compile(r'(^VAR\s+\S+\s+"?(?:device|ups)\.serial"?\s+")([^"\n]*)(")', re.M)
|
|
for path in sorted(fix.iterdir()):
|
|
if path.suffix not in (".json", ".txt", ".vars"):
|
|
continue
|
|
text = path.read_text()
|
|
seen: dict[str, str] = {}
|
|
serials: dict[str, str] = {}
|
|
|
|
def swap(match: re.Match[str]) -> str:
|
|
value = match.group(0)
|
|
if value.startswith(SYNTHETIC):
|
|
return value
|
|
if value not in seen:
|
|
seen[value] = f"00000000-0000-4000-8000-{len(seen) + 1:012d}"
|
|
return seen[value]
|
|
|
|
text = uuid_re.sub(swap, text)
|
|
# "hostname": "gw.lab.example.com" -> "gw": the dashboard's host key is the target name
|
|
# from HOST_TARGETS, never whatever the box calls itself
|
|
text = re.sub(r'("hostname": "[^"]*?)\.[^"]*(")', r"\1\2", text)
|
|
if path.name.startswith("slots_"):
|
|
# only non-empty values are replaced, so an idle capture does not churn
|
|
text = re.sub(r'("prompt"\s*:\s*)"(?:[^"\\]|\\.)+"', r'\1""', text)
|
|
|
|
def scrub_serial(match: re.Match[str]) -> str:
|
|
value = match.group(2)
|
|
body, pad = value.rstrip(), value[len(value.rstrip()):]
|
|
if body.startswith("SCRUBBEDSERIAL"):
|
|
return match.group(0)
|
|
if body not in serials:
|
|
serials[body] = f"SCRUBBEDSERIAL{len(serials) + 1}"
|
|
# trailing spaces are a real quirk of the apc driver — keep the padding verbatim
|
|
return match.group(1) + serials[body] + pad + match.group(3)
|
|
|
|
text = serial_re.sub(scrub_serial, text)
|
|
if text != path.read_text():
|
|
path.write_text(text)
|
|
changed.append(path.name)
|
|
print(f" scrubbed identifiers in: {', '.join(changed)}" if changed else " fixtures carry no identifiers")
|
|
PY
|
|
|
|
exit "$failed"
|