feat(plugin): Phase 2 partial — vault_status migrated + brand skin moved + eval suite (P2.B/C, P3.A/B)
plugin-tests / test (push) Successful in 25s
plugin-tests / test (push) Successful in 25s
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>
This commit is contained in:
@@ -28,7 +28,6 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
MAP_PATH = REPO / "CONNECTION-MAP.md"
|
||||
@@ -154,11 +153,9 @@ def generate():
|
||||
frontend_rows = _scan_frontend()
|
||||
|
||||
total = len(public_rows) + len(internal_rows) + len(frontend_rows)
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
out = [
|
||||
"# CONNECTION MAP — svrnty-hermes-webui-plugin → nesquena/hermes-webui",
|
||||
"",
|
||||
f"**Generated:** {now} ",
|
||||
f"**Upstream version:** {_upstream_version()} ",
|
||||
f"**Plugin version:** {_plugin_version()} ",
|
||||
f"**Total dependencies:** {total} ({len(public_rows)} public API · "
|
||||
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/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()
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""upstream-sync.py — fetch upstream tags + run plugin matrix against new ones.
|
||||
|
||||
Exit 0 if every new tag passed boot-smoke + tests + map-check. Exit 1 if any
|
||||
tag failed. Posts a JSON report on stdout (and to --report-json file when set)
|
||||
that lists the per-tag verdict for downstream tooling.
|
||||
|
||||
Used by:
|
||||
- Makefile `make sync-upstream` target (manual)
|
||||
- .github/workflows/upstream-drift.yml (daily cron)
|
||||
|
||||
Tooling-light: only stdlib + requests + pytest (already plugin deps).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_REPO = Path(__file__).resolve().parent.parent
|
||||
FORK_REPO = PLUGIN_REPO.parent / "hermes-webui"
|
||||
|
||||
|
||||
def _git(*args, cwd=None, check=True):
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=cwd or PLUGIN_REPO,
|
||||
capture_output=True, text=True, check=check,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _fetch_upstream():
|
||||
"""git fetch upstream in the fork repo. Returns list of new tags vs HEAD."""
|
||||
if not (FORK_REPO / ".git").exists():
|
||||
return []
|
||||
_git("fetch", "upstream", "--tags", cwd=str(FORK_REPO), check=False)
|
||||
raw = _git("tag", "--list", "v*", cwd=str(FORK_REPO), check=False)
|
||||
return sorted(raw.splitlines()) if raw else []
|
||||
|
||||
|
||||
def _current_tested():
|
||||
"""Return manifest.yaml's tested_versions list."""
|
||||
mf = PLUGIN_REPO / "manifest.yaml"
|
||||
if not mf.exists():
|
||||
return []
|
||||
tested = []
|
||||
in_block = False
|
||||
for line in mf.read_text().splitlines():
|
||||
if line.strip().startswith("tested_versions:"):
|
||||
in_block = True
|
||||
continue
|
||||
if in_block:
|
||||
if line.strip().startswith("- "):
|
||||
tested.append(line.strip()[2:].strip())
|
||||
elif line and not line[0].isspace():
|
||||
break
|
||||
return tested
|
||||
|
||||
|
||||
def _run_check(name, cmd):
|
||||
"""Run a check command, return {ok, name, summary}."""
|
||||
try:
|
||||
out = subprocess.run(cmd, cwd=PLUGIN_REPO, capture_output=True, text=True, timeout=180)
|
||||
return {"name": name, "ok": out.returncode == 0,
|
||||
"summary": (out.stdout + out.stderr)[-200:]}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"name": name, "ok": False, "summary": "timeout"}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--report-json", default=None, help="Write full report here")
|
||||
ap.add_argument("--tag", default=None, help="Test against this specific tag only")
|
||||
args = ap.parse_args()
|
||||
|
||||
all_tags = _fetch_upstream()
|
||||
tested = set(_current_tested())
|
||||
candidates = [args.tag] if args.tag else [t for t in all_tags if t not in tested]
|
||||
|
||||
if not candidates:
|
||||
report = {"status": "no-new-tags", "all_tags": all_tags[-5:], "tested": list(tested),
|
||||
"generated_at": datetime.now(timezone.utc).isoformat()}
|
||||
if args.report_json:
|
||||
Path(args.report_json).write_text(json.dumps(report, indent=2))
|
||||
print(json.dumps(report, indent=2))
|
||||
sys.exit(0)
|
||||
|
||||
matrix = []
|
||||
overall_ok = True
|
||||
for tag in candidates[-3:]: # cap at 3 newest to avoid runaway runs
|
||||
checks = []
|
||||
# ① Connection map fresh against current plugin
|
||||
checks.append(_run_check("connection-map-check",
|
||||
["python3", "scripts/ast-connection-map.py", "--check"]))
|
||||
# ② Unit + integration tests pass
|
||||
checks.append(_run_check("pytest-unit",
|
||||
["python3", "-m", "pytest", "tests/unit", "-q"]))
|
||||
checks.append(_run_check("pytest-integration",
|
||||
["python3", "-m", "pytest", "tests/integration", "-q"]))
|
||||
checks.append(_run_check("pytest-evals",
|
||||
["python3", "-m", "pytest", "tests/evals", "-q"]))
|
||||
# ③ Boot smoke (uses fork at current state — caller's responsibility to checkout the tag)
|
||||
# The CI workflow handles the checkout-each-tag dance.
|
||||
checks.append(_run_check("boot-smoke",
|
||||
["python3", "scripts/boot-smoke.py"]))
|
||||
tag_ok = all(c["ok"] for c in checks)
|
||||
overall_ok = overall_ok and tag_ok
|
||||
matrix.append({"tag": tag, "ok": tag_ok, "checks": checks})
|
||||
|
||||
report = {
|
||||
"status": "ok" if overall_ok else "fail",
|
||||
"tested": list(tested),
|
||||
"new_candidates": candidates,
|
||||
"matrix": matrix,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if args.report_json:
|
||||
Path(args.report_json).write_text(json.dumps(report, indent=2))
|
||||
print(json.dumps(report, indent=2))
|
||||
sys.exit(0 if overall_ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user