Systemaudit vom 27.08.2026. Alle Befunde gemessen, nicht vermutet.
VIER STILLE DEFEKTE
1. mc2-steward startete seit Wochen nicht (live: 207.609 Neustarts).
steward.py importierte services.memory, das beim Mem0-Ausbau geloescht
wurde -> ImportError bei jedem Start. Re-Warm- und Health-Waechter
waren damit tot.
2. Jedes Hermes-Update wurde automatisch zurueckgerollt.
hermes-postcheck.sh prueft vier Dinge, die es seit dem 07.08. nicht mehr
gibt (Sidecar :8765, /api/memory, memory.provider, mc2-memory-Plugin).
Die Checks konnten nicht gruen werden -> autoupdate.sh wertete jedes
Update als rot und rollte es zurueck. Checks ersatzlos entfernt; der
Tool-Smoke laeuft ohnehin durch den echten Agenten.
3. 7 von 12 Skills waren per Knopfdruck nicht startbar.
deploy.sh kopiert Skills mit tr '-' '_' nach ~/.hermes/skills/,
routers/skills.py gab Hermes aber den Ordnernamen MIT Bindestrich.
Der Knopf meldete Erfolg, ausgefuehrt wurde nichts. Neu: _hermes_name().
4. deploy.sh warf bei jedem Deploy die Live-Modellkonfiguration weg.
MC2 schreibt /etc/llama-swap/config.yaml selbst; die Repo-Datei ist nur
ein Abzug (ihm fehlt u.a. kritiker/Devstral). Jetzt: erst sichern, Diff
zeigen, dann kopieren. MC_DEPLOY_SKIP_SWAP_CONFIG=1 ueberspringt.
MEM0-AUSBAU VOLLENDET (Kriterium 3: 17 -> 0 Dateien)
- mem0_service/, mcp/mcp_memory.py und hermes/plugins/mc2-memory entfernt;
das Plugin schickte bei JEDEM Turn zwei 404-Requests an tote Routen.
- MEMORY_DB/MEM0_SERVICE_URL, _mem0_reachable(), MC_MEMORY_DB und
MC_MEM_DEDUPE_ENABLED aus Config/Router/Unit entfernt.
- mem0_ms war strukturell tot (park("retrieve") wird nirgends mehr
aufgerufen) -> aus Backend, API-Typ und Latenzkarte entfernt.
- Verbinden-Tab: tote Gedaechtnis-MCP-Leitung raus, Status-Kachel bleibt.
- AGENTS.md beschrieb Mem0 noch als aktiv - korrigiert.
GATEWAY-ROBUSTHEIT
- _proxy gab bei ungueltigen Payloads HTTP 500 (gemessen 5/5: Rohtext,
leerer Body, JSON-Liste, JSON-String, null) -> jetzt 5/5 HTTP 400.
- Bild-Weiche ohne Deckel: 10 Bilder x 2 Versuche x 240 s hielten den
Client bis zu 80 min. Neu: MC_CODER_IMAGE_MAX (4), Rueckfall auf die
Vision-Umleitung.
- /v1/models: nicht-JSON von der Engine gab 500 -> jetzt 502.
UNITS UND DEPLOY
- mc2-steward.service, dessen warmset-Drop-in und voice-service.service
fehlten im Repo, obwohl maintenance.py und stack-postcheck.sh sie
voraussetzen. 1:1 von der laufenden Box uebernommen.
- deploy.sh startete mc2-steward nie neu; restore.sh liess mc2-gateway und
mc2-steward mit alter Config weiterlaufen. Beide ergaenzt.
FRONTEND
- useEigenleben rief /api/eigenleben - existiert im Backend nicht und wurde
nirgends genutzt. Samt Typen entfernt.
- Anleitung beschrieb einen Gedaechtnis-Tab, den es nicht gibt.
- Abgeglichen: alle uebrigen 63 Frontend-Aufrufe treffen echte Routen, alle
5 SSE-Invalidation-Keys sind gemappt, keine ungefangenen Promises.
Gates: compileall gruen - ruff "All checks passed" - tsc gruen - vite build
gruen (dist aktualisiert) - Importe app/steward/gateway_app gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
6.8 KiB
Python
167 lines
6.8 KiB
Python
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
|
|
|
|
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
|
|
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
|
|
zurückgegeben statt zu crashen.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
|
|
import httpx
|
|
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
|
from fastapi import APIRouter
|
|
from pydantic import BaseModel
|
|
from services import backup as backup_svc
|
|
from services import maintenance
|
|
from services.agent import agent_status
|
|
from services.gateway import gateway_reachable
|
|
from services.llamaswap import engine_reachable, list_models
|
|
from services.pricing import compute_savings
|
|
from services.system import system_status
|
|
from services.token_stats import get_stats
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api")
|
|
|
|
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
|
|
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
|
|
|
|
|
|
@router.get("/system/status")
|
|
def status() -> dict:
|
|
return system_status()
|
|
|
|
|
|
@router.get("/system/history")
|
|
def history(minutes: int = 60) -> dict:
|
|
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
|
|
from services import metrics_history
|
|
return metrics_history.history(minutes)
|
|
|
|
|
|
def _voice_reachable() -> bool:
|
|
try:
|
|
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _user_unit_active(unit: str) -> bool:
|
|
"""systemd-User-Unit ohne eigenen HTTP-Port (z. B. mc2-steward) — Zustand via is-active.
|
|
Auf Windows/Dev schlägt das harmlos fehl (False)."""
|
|
if os.name != "posix":
|
|
return False
|
|
try:
|
|
r = subprocess.run(["systemctl", "--user", "is-active", unit],
|
|
capture_output=True, text=True, timeout=3)
|
|
return r.stdout.strip() == "active"
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
@router.get("/system/services")
|
|
def services() -> dict:
|
|
"""Aggregierte Erreichbarkeit aller Stack-Dienste — die EINE Quelle für Cockpit-Kachel,
|
|
Schubladen-Liste und Log-Auswahl (Frontend rendert diese Liste, keine UI-Kopie mehr).
|
|
`scope`: user|system (Restart-Weg), `unit`: echter systemd-Name (Restart/Logs/Matching)."""
|
|
a = agent_status()
|
|
gw_url = f"{GATEWAY_URL}/v1"
|
|
rows = [
|
|
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
|
|
"ok": engine_reachable(), "scope": "system"},
|
|
]
|
|
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
|
|
# in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad).
|
|
if V1_UPSTREAM:
|
|
rows.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM,
|
|
"ok": gateway_reachable(), "scope": "user"})
|
|
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
|
|
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
|
|
rows.append({"name": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url,
|
|
"ok": True, "scope": "user"})
|
|
else:
|
|
rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
|
|
"ok": gateway_reachable(), "scope": "user"})
|
|
rows += [
|
|
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
|
|
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
|
|
{"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
|
"ok": a["gateway_reachable"], "scope": "user"},
|
|
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
|
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
|
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
|
"ok": _voice_reachable(), "scope": "user"},
|
|
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
|
|
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
|
|
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
|
|
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
|
|
]
|
|
return {
|
|
"services": rows,
|
|
"links": {
|
|
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
|
|
"gateway": gw_url,
|
|
"hermes_ui": a["hermes_ui_url"],
|
|
},
|
|
}
|
|
|
|
|
|
@router.post("/system/backup")
|
|
def backup() -> dict:
|
|
return backup_svc.backup_now()
|
|
|
|
|
|
@router.get("/system/backups")
|
|
def backups() -> dict:
|
|
return {"backups": backup_svc.list_backups()}
|
|
|
|
|
|
def _run(cmd: list[str], cwd: str | None = None) -> dict:
|
|
try:
|
|
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
|
return {"ok": p.returncode == 0, "code": p.returncode,
|
|
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
|
except Exception as exc:
|
|
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
|
|
|
|
|
class RestartReq(BaseModel):
|
|
service: str
|
|
|
|
|
|
@router.post("/system/restart")
|
|
def restart(req: RestartReq) -> dict:
|
|
"""Dienst neustarten. Delegiert an den Wartungs-Service (EINE Allowlist-Wahrheit):
|
|
der kennt System-Dienste (llama-swap, via sudo -n) UND User-Dienste und wird auch von
|
|
der UI (/api/maintenance/restart) genutzt. Vorher lag hier eine zweite, veraltete Liste
|
|
(ohne llama-swap/hermes-terminal, mit Geist-Eintrag hermes-webui) → Engine-Neustart per
|
|
Sprache/MCP schlug fehl."""
|
|
return maintenance.restart_service(req.service)
|
|
|
|
|
|
@router.post("/system/self-update")
|
|
def self_update() -> dict:
|
|
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
|
|
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
|
|
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
|
|
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
|
|
return {"pull": pull, "reset": reset, "restart": restart_res}
|
|
|
|
|
|
@router.get("/system/token-stats")
|
|
def token_stats() -> dict:
|
|
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
|
|
# Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen.
|
|
role_map: dict[str, str | None] = {}
|
|
try:
|
|
for m in list_models():
|
|
role_map[m["name"].lower()] = m.get("role")
|
|
for alias in m.get("aliases", []):
|
|
role_map[alias.lower()] = m.get("role")
|
|
except Exception:
|
|
log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True)
|
|
return compute_savings(get_stats(), role_map)
|