feat(v9): Phase 4 — Agent-Cockpit (Status/Cron/Skills) im Dashboard
Macht das "geschenkte v9" sichtbar. Read-only Control-Plane-Reads:
- hermes_control.py: gateway_state.json + cron-Heartbeat (Status),
`hermes cron list` + `hermes skills list` via COLUMNS=400 (kein
Rich-Truncation) als kanonische Quelle, .usage.json fuer Nutzung.
TTL-Cache (10s) gegen Polling-Last.
- routers/hermes.py: GET /api/hermes/{agent,cron,skills}.
- HermesPanel: aufklappbares "Cockpit" — Status-Badges, Cron-Jobs,
Skills (sortiert nach Nutzung, aktiv/inaktiv).
- config.py: HERMES_HOME, HERMES_BIN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Hermes-Agent Control-Plane — read-only Einblicke fuer das Dashboard (Phase 4).
|
||||
|
||||
Macht das "geschenkte v9" sichtbar: Agent-Status, Cron-Scheduler und Skills des
|
||||
Nous-Hermes-Agent-Frameworks. Quellen (MC laeuft als hitonabi auf dem Bosgame,
|
||||
also direkter Zugriff):
|
||||
|
||||
- Status : ~/.hermes/gateway_state.json + cron-Heartbeat-Datei (sauberes JSON,
|
||||
kein Parsing). Liveness kommt aus dem Heartbeat, nicht aus dem evtl.
|
||||
beim Start eingefrorenen gateway_state.json.
|
||||
- Cron : `hermes cron list --all` (kanonisch; kein stabiles Storage-Schema).
|
||||
- Skills : `hermes skills list` (kanonisch) + ~/.hermes/skills/.usage.json
|
||||
(Nutzungszaehler). Rich-Tabellen werden mit COLUMNS=400 ohne
|
||||
Truncation erzeugt und ueber die `│`/`┃`-Spalten geparst.
|
||||
|
||||
Alles mit kurzem TTL-Cache, damit Dashboard-Polling die CLI nicht haemmert.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from config import HERMES_HOME, HERMES_BIN
|
||||
|
||||
_CACHE_TTL = 10.0 # Sekunden
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
|
||||
|
||||
def _cached(key: str, fn: Callable[[], object]) -> object:
|
||||
now = time.monotonic()
|
||||
hit = _cache.get(key)
|
||||
if hit and now - hit[0] < _CACHE_TTL:
|
||||
return hit[1]
|
||||
val = fn()
|
||||
_cache[key] = (now, val)
|
||||
return val
|
||||
|
||||
|
||||
def _run_cli(args: list[str], timeout: float = 45.0) -> str:
|
||||
"""`hermes <args>` ausfuehren. COLUMNS=400 verhindert Rich-Truncation."""
|
||||
env = dict(os.environ, COLUMNS="400", NO_COLOR="1")
|
||||
try:
|
||||
p = subprocess.run(
|
||||
[HERMES_BIN, *args],
|
||||
capture_output=True, text=True, timeout=timeout, env=env,
|
||||
)
|
||||
return p.stdout or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_rich_table(text: str) -> list[dict]:
|
||||
"""Eine Rich-Box-Tabelle in list[dict] verwandeln.
|
||||
|
||||
Erste Zeile mit Spaltentrennern (`│` oder `┃`) ist der Header, der Rest sind
|
||||
Datenzeilen. Trenn-/Rahmenzeilen (┏━┓ usw.) enthalten keine Trenner -> ignoriert.
|
||||
"""
|
||||
header: list[str] | None = None
|
||||
rows: list[list[str]] = []
|
||||
for ln in text.splitlines():
|
||||
if "│" not in ln and "┃" not in ln:
|
||||
continue
|
||||
cells = [c.strip() for c in re.split(r"[│┃]", ln)]
|
||||
# fuehrende/abschliessende Rahmen-Leerzellen entfernen
|
||||
while cells and cells[0] == "":
|
||||
cells.pop(0)
|
||||
while cells and cells[-1] == "":
|
||||
cells.pop()
|
||||
if not cells:
|
||||
continue
|
||||
if header is None:
|
||||
header = [c.lower() for c in cells]
|
||||
else:
|
||||
rows.append(cells)
|
||||
if not header:
|
||||
return []
|
||||
out = []
|
||||
for r in rows:
|
||||
if len(r) != len(header):
|
||||
continue
|
||||
out.append(dict(zip(header, r)))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Oeffentliche Reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def agent_status() -> dict:
|
||||
"""Live-Status des Hermes-Gateways + Cron-Tickers."""
|
||||
def _load() -> dict:
|
||||
st = {
|
||||
"gateway_state": "unknown",
|
||||
"pid": None,
|
||||
"active_agents": None,
|
||||
"api_server": "unknown",
|
||||
"cron_running": False,
|
||||
"heartbeat_age_s": None,
|
||||
"version": None,
|
||||
}
|
||||
try:
|
||||
d = json.loads((HERMES_HOME / "gateway_state.json").read_text())
|
||||
st["gateway_state"] = d.get("gateway_state", "unknown")
|
||||
st["pid"] = d.get("pid")
|
||||
st["active_agents"] = d.get("active_agents")
|
||||
api = (d.get("platforms") or {}).get("api_server") or {}
|
||||
st["api_server"] = api.get("state", "unknown")
|
||||
except Exception:
|
||||
pass
|
||||
# Liveness: Cron-Ticker-Heartbeat (Unix-Timestamp). < 120s = laeuft.
|
||||
try:
|
||||
ts = float((HERMES_HOME / "cron" / "ticker_heartbeat").read_text().strip())
|
||||
age = max(0, int(time.time() - ts))
|
||||
st["heartbeat_age_s"] = age
|
||||
st["cron_running"] = age < 120
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
st["version"] = (HERMES_HOME / "PINNED_VERSION").read_text().strip() or None
|
||||
except Exception:
|
||||
pass
|
||||
return st
|
||||
|
||||
return _cached("agent", _load)
|
||||
|
||||
|
||||
def cron_jobs() -> list[dict]:
|
||||
"""Geplante Jobs (inkl. pausierter)."""
|
||||
return _cached("cron", lambda: _parse_rich_table(_run_cli(["cron", "list", "--all"]))) # type: ignore[return-value]
|
||||
|
||||
|
||||
def skills() -> list[dict]:
|
||||
"""Installierte Skills, angereichert um Nutzungszaehler aus .usage.json."""
|
||||
def _load() -> list[dict]:
|
||||
rows = _parse_rich_table(_run_cli(["skills", "list"]))
|
||||
usage = {}
|
||||
try:
|
||||
usage = json.loads((HERMES_HOME / "skills" / ".usage.json").read_text())
|
||||
except Exception:
|
||||
pass
|
||||
for r in rows:
|
||||
u = usage.get(r.get("name", ""), {})
|
||||
r["use_count"] = u.get("use_count", 0)
|
||||
r["last_used_at"] = u.get("last_used_at")
|
||||
r["pinned"] = bool(u.get("pinned", False))
|
||||
return rows
|
||||
|
||||
return _cached("skills", _load) # type: ignore[return-value]
|
||||
Reference in New Issue
Block a user