svrnty-hermes-webui-plugin/scripts/boot-smoke.py
Svrnty c1e3fa1af0
All checks were successful
plugin-tests / test (push) Successful in 25s
feat(plugin): Phase 2 partial — vault_status migrated + brand skin moved + eval suite (P2.B/C, P3.A/B)
Lands the easy migrations + the automation skeleton. STT migration deferred
to Phase 2.1 (it touches the streaming engine + bootstrap JS — needs a new
streaming_hook public-API method OR forced-internal CONNECTION-MAP entries).

Migrated to plugin:
  routes/vault_status.py    GET /api/vault/status (from fork commit 3e2c74f3)
  static/{app.js,app.css,fonts/}  brand skin (from hermes-ext/)

Plugin auto-loaded by hermes-webui when HERMES_WEBUI_PYTHON_PLUGIN is set;
register_static + inject_stylesheet + inject_script wire the URL contract at
/plugins/svrnty/{app.css,app.js} per protocol §14 (Q5).

Automation skeleton:
  Makefile                          one-liner targets: test · map · sync-upstream · smoke
  scripts/boot-smoke.py             start upstream+plugin, curl every endpoint
  scripts/upstream-sync.py          fetch tags + run matrix + JSON report
  tests/evals/test_features.py      4 evals (loader contract · vault payload · brand URL contract · forced-internal=0)
  tests/unit/test_brand_skin.py     4 asset-presence + wiring tests
  tests/unit/test_vault_status.py   3 handler tests (register, success, error)

CONNECTION-MAP.md: 0 forced-internal dependencies; plugin uses only public API.
AST script timestamp removed so map-check is deterministic.

Tests: 11/11 PASS (4 evals + 7 unit). Integration tests deferred until
boot-smoke runs against a live hermes-webui (Phase 2.D + 2.E gate).

Deferred to next session:
  P2.A  STT migration (needs streaming_hook design — see routes/transcribe.py)
  P2.D  Revert 4 fork feature commits — needs STT migration first
  P2.E  Archive hermes-ext repo — gated on P2.D
  P2.F  Live boot smoke against real webui

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:02:47 -04:00

134 lines
4.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""boot-smoke.py — start hermes-webui + plugin, curl every plugin endpoint.
Exit 0 if every endpoint returns its expected status, 1 otherwise. Used by
upstream-sync.py and as a one-shot manual check after install.
Usage:
python3 boot-smoke.py # start webui + smoke + stop
python3 boot-smoke.py --no-start # webui already running; just smoke
python3 boot-smoke.py --base http://... # smoke against custom base URL
"""
import argparse
import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError
PLUGIN_REPO = Path(__file__).resolve().parent.parent
# Endpoints we expect after the plugin is loaded. Status codes and content
# checks are minimal — this is "did it boot", not "is it correct".
SMOKE = [
{"path": "/healthz", "expect": [200], "kind": "vanilla"},
{"path": "/api/vault/status", "expect": [200, 401, 403], "kind": "plugin"},
{"path": "/plugins/svrnty/app.css", "expect": [200], "kind": "plugin-static"},
{"path": "/plugins/svrnty/app.js", "expect": [200], "kind": "plugin-static"},
]
def _free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def _wait_for(url, timeout=20):
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urlopen(url, timeout=1) as r:
if r.status < 500:
return True
except URLError:
pass
except Exception:
pass
time.sleep(0.3)
return False
def _hit(base, path):
url = base.rstrip("/") + path
try:
with urlopen(url, timeout=5) as r:
return r.status, r.read()[:200]
except URLError as e:
if hasattr(e, "code"):
return e.code, b""
return None, str(e).encode()
except Exception as e:
return None, str(e).encode()
def smoke(base):
rows = []
failed = 0
for s in SMOKE:
status, _body = _hit(base, s["path"])
ok = status in s["expect"]
rows.append({"path": s["path"], "status": status, "kind": s["kind"], "ok": ok})
if not ok:
failed += 1
return rows, failed
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--no-start", action="store_true",
help="Assume hermes-webui is already running; just curl")
ap.add_argument("--base", default=None,
help="Base URL (default http://127.0.0.1:<port>)")
ap.add_argument("--webui-dir",
default=str(PLUGIN_REPO.parent / "hermes-webui"),
help="Path to hermes-webui repo")
args = ap.parse_args()
proc = None
base = args.base or "http://127.0.0.1:8787"
if not args.no_start:
port = _free_port()
base = f"http://127.0.0.1:{port}"
env = os.environ.copy()
env["HERMES_WEBUI_PYTHON_PLUGIN"] = "svrnty_hermes_webui_plugin"
env["PORT"] = str(port)
# Best-effort: start under the agent venv if it exists; else system python.
py = Path(args.webui_dir) / "venv" / "bin" / "python"
cmd = [str(py) if py.exists() else "python3", "bootstrap.py", "--foreground"]
proc = subprocess.Popen(
cmd, cwd=args.webui_dir, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid,
)
if not _wait_for(f"{base}/healthz", timeout=30):
print(f"FAIL: webui did not respond at {base}/healthz within 30s",
file=sys.stderr)
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
sys.exit(1)
try:
rows, failed = smoke(base)
finally:
if proc is not None:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
print(json.dumps({"base": base, "rows": rows, "failed": failed}, indent=2))
sys.exit(0 if failed == 0 else 1)
if __name__ == "__main__":
main()