"""Die Knöpfe des Homelab Orchestrators für Lucy (seit 25.09.2026, User-Wunsch: „Lucy ist die Systemadministratorin und Assistentin — bau das mit Lucy und dem Orchestrator“). Reine Funktionen über die REST-API von MC2 auf der KI-Box (:9001); den Homelab-Teil erreicht sie über die Durchreiche /api/partner/homelab/…. Sie tun genau, was die Knöpfe der Oberfläche tun, mit deren Leitplanken: Snapshot oder Sicherung vorher, Prüfung danach, bei Rot zurück, Sperren gegen gleichzeitige Läufe, die Neustart-Allowlist. Nichts hier geht an Proxmox oder die Gäste vorbei. mcp_orchestrator.py macht MCP-Werkzeuge daraus. Antworten sind kurze deutsche Texte statt JSON — Lucy soll sie lesen und dem Commander berichten, nicht parsen. Jede Aktion steht mit dem Grund (der Bitte des Commanders) in ~/.hermes/logs/orchestrator-werkzeuge.log. """ from __future__ import annotations import json import os from datetime import datetime from pathlib import Path from typing import Any from zoneinfo import ZoneInfo import httpx MC_URL = os.environ.get("MC_URL", "http://127.0.0.1:9001").rstrip("/") ORTSZEIT = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) # wie kern/zeit.py (AGENTS.md: Zeit) HL = "/api/partner/homelab" LOG = Path(os.environ.get("MC_ORCH_LOG", str(Path.home() / ".hermes" / "logs" / "orchestrator-werkzeuge.log"))) KI_BOX_ALLE = "/api/maintenance/update-all" # Die Beschreibungen der MCP-Werkzeuge (mcp_orchestrator.py) — als Leitplanken formuliert. Hier, damit ein Test prüft, # dass jede Aktion die Leitplanke trägt. _HANDELN = (" Nur aufrufen, wenn der Commander es in diesem Gespräch ausdrücklich verlangt oder auf deine Rückfrage " "bestätigt hat — nie auf eigene Initiative, auch nicht in geplanten Läufen. grund: seine Bitte in seinen " "Worten (wird protokolliert).") BESCHREIBUNG = { "lage": ("Kurzbericht über KI-Box und Homelab: Hinweise beider Wächter, erreichbare Geräte, Zahl offener Updates, " "Speicherpool, Systemplatte, NAS, Sicherungen und was gerade läuft. Nutze das, wenn der Commander wissen " "will, wie es steht, oder bevor du etwas startest."), "updates": ("Offene Updates je Gerät (KI-Box und Homelab) mit den Kennungen ziel und baustein für update_starten — " "und bei denen ohne Knopf der Grund (etwa die 48-Stunden-Wartezeit nach einer Skriptänderung)."), "speicher": ("Speicherpool, Systemplatte des Proxmox-Hosts, NAS und nächtliche Sicherungen, dazu Vorschläge, was " "Platz bringt."), "protokoll": "Was im Homelab zuletzt geschah — Updates, Meldungen, Hinweise —, neueste zuerst. anzahl: höchstens 60.", "radar": "Kandidaten des Modell-Radars mit Status und ob „Jetzt testen“ gerade geht (sonst der Grund).", "update_starten": ("Ein einzelnes Update starten, genau wie der Knopf auf der Update-Seite: vorher Snapshot oder " "Sicherung, danach Prüfung, bei Rot automatisch zurück. ziel und baustein aus updates() (etwa " "ziel=\"ct-104\", baustein=\"os\"; KI-Box: ziel=\"ki-box\", baustein=\"os\"/\"engine\"/\"swap\"/" "\"hermes\" — bei \"hermes\" bist du kurz nicht erreichbar)." + _HANDELN), "alle_aktualisieren": ("„Alle aktualisieren“ für bereich=\"homelab\" (Gäste nacheinander, Host-Pakete zuletzt, nie " "Neustart, hält bei Rot an) oder bereich=\"ki-box\" (Motor, llama-swap, Hermes, " "Betriebssystem; beim Hermes-Schritt bist du kurz weg)." + _HANDELN), "nach_updates_suchen": "Die KI-Box nach neuen Updates suchen lassen (Paketlisten, Motor, llama-swap, Hermes)." + _HANDELN, "dienst_neustarten": ("Einen Dienst der KI-Box neu starten (etwa \"llama-swap\", \"hermes-builtin-ui\", " "\"mission-control-2\"). Erlaubt ist nur, was die Neustart-Liste von MC2 kennt. Einen " "hängenden Dienst darfst du auch ohne Bitte neu starten (Reparatur) — sag es dem Commander " "danach. grund: warum (wird protokolliert)."), "kernel_aufraeumen": ("Alte Kernel vom Proxmox-Host entfernen — nur, was Proxmox selbst für entbehrlich hält; der " "laufende und der für den nächsten Start bleiben. Die Gäste merken nichts." + _HANDELN), "host_neustarten": ("Den Proxmox-Host neu starten. ALLE Container und die Arcane-VM sind dann ein paar Minuten weg, " "auch AdGuard (die Namensauflösung im Heimnetz). Sag das dem Commander vorher und frag nach, auch " "wenn er es verlangt hat." + _HANDELN), "radar_testen": ("Einen Kandidaten des Modell-Radars jetzt testen (id aus radar()). Geht nur, wenn er samt Coder in " "den Speicher passt und nicht zwischen 00:00 und 03:30; Lucy und NerdQuiz sind währenddessen etwas " "langsamer." + _HANDELN), "radar_suchen": "Das Modell-Radar jetzt nach neuen Modellen suchen lassen (Merkliste und Hugging Face)." + _HANDELN, } class Fehler(Exception): """MC2 hat abgelehnt oder war nicht erreichbar — der Text geht so an Lucy.""" def _get(pfad: str, **params: Any) -> Any: try: r = httpx.get(f"{MC_URL}{pfad}", params=params or None, timeout=30) except httpx.HTTPError as exc: raise Fehler(f"MC2 ist nicht erreichbar ({exc.__class__.__name__}).") from exc if r.status_code >= 400: raise Fehler(_grund(r)) return r.json() def _post(pfad: str, daten: dict | None = None) -> dict: """POST wie ein Knopf der Oberfläche. MC2 antwortet mit ok/detail oder HTTP-Fehler samt detail.""" try: r = httpx.post(f"{MC_URL}{pfad}", json=daten or {}, timeout=60) except httpx.HTTPError as exc: raise Fehler(f"MC2 ist nicht erreichbar ({exc.__class__.__name__}).") from exc if r.status_code >= 400: raise Fehler(_grund(r)) antwort = r.json() if r.content else {} if isinstance(antwort, dict) and antwort.get("ok") is False: raise Fehler(str(antwort.get("detail") or antwort.get("text") or "MC2 hat abgelehnt.")) return antwort if isinstance(antwort, dict) else {} def _grund(r: httpx.Response) -> str: try: detail = r.json().get("detail") except (ValueError, AttributeError): detail = None return str(detail or f"MC2 antwortete mit HTTP {r.status_code}.") def _homelab_pfad(pfad: str) -> str: """Pfade aus den Knöpfen des Homelab-Teils (/api/homelab/…) laufen auf der Box über die Durchreiche.""" return HL + pfad[len("/api/homelab"):] if pfad.startswith("/api/homelab") else pfad def _protokolliere(werkzeug: str, argumente: dict, grund: str, ergebnis: str) -> None: try: LOG.parent.mkdir(parents=True, exist_ok=True) with LOG.open("a", encoding="utf-8") as f: f.write(json.dumps({"zeit": datetime.now().isoformat(timespec="seconds"), "werkzeug": werkzeug, "argumente": argumente, "grund": grund, "ergebnis": ergebnis}, ensure_ascii=False) + "\n") except OSError: pass def _handeln(werkzeug: str, argumente: dict, grund: str, tun) -> str: """Eine Aktion ausführen und protokollieren. Ohne Grund keine Aktion — er ist der Nachweis, dass der Commander darum gebeten hat.""" if not str(grund or "").strip(): text = ("Nicht ausgeführt: Es fehlt der Grund, also die Bitte des Commanders in seinen Worten. Aktionen nur, " "wenn er sie verlangt oder bestätigt hat.") else: try: text = tun() except Fehler as exc: text = f"Nicht ausgeführt: {exc}" _protokolliere(werkzeug, argumente, str(grund or ""), text) return text # --- Lesen ---------------------------------------------------------------------------------------------------- def _ziele() -> list[dict]: """KI-Box und Homelab als Ziele mit Bausteinen — dasselbe, was die Update-Seiten zeigen.""" ziele = list(_get("/api/ziele").get("ziele") or []) try: ziele += list(_get(f"{HL}/ziele").get("ziele") or []) except Fehler: pass return ziele def _offen(z: dict) -> list[dict]: return [b for b in z.get("bausteine") or [] if b.get("zustand") == "neu"] def lage() -> str: """Kurzbericht: Hinweise beider Wächter, offene Updates, Speicher/NAS/Sicherungen, was gerade läuft.""" zeilen: list[str] = [] for wo, pfad in (("KI-Box", "/api/hinweise"), ("Homelab", f"{HL}/hinweise")): try: hinweise = _get(pfad).get("hinweise") or [] except Fehler as exc: zeilen.append(f"{wo}: nicht erreichbar ({exc}).") continue if hinweise: zeilen += [f"{wo}, {h.get('stufe')}: {h.get('titel')}. {h.get('text') or ''}".strip() for h in hinweise] else: zeilen.append(f"{wo}: keine Hinweise.") ziele = _ziele() offen = [(z, b) for z in ziele for b in _offen(z)] zeilen.append(f"Geräte: {sum(1 for z in ziele if z.get('erreichbar') is not False)} von {len(ziele)} erreichbar; " f"{len(offen)} Updates stehen an" + (" (Liste mit updates())." if offen else ".")) try: zeilen += _speicher_zeilen(_get(f"{HL}/speicher")) except Fehler: pass zeilen += _laeuft() return "\n".join(zeilen) def _speicher_zeilen(s: dict) -> list[str]: zeilen = [] for p in s.get("pools") or []: zeilen.append(f"Speicherpool {p['speicher']}: {round(p['anteil'] * 100)} % belegt ({p['stufe']}).") if sp := s.get("systemplatte"): zeilen.append(f"Systemplatte des Proxmox-Hosts: {round(sp['anteil'] * 100)} % ({sp['stufe']}).") for n in s.get("nas") or []: zeilen.append(f"NAS {n.get('server')}: " + (n.get("problem") or f"{round((n.get('anteil') or 0) * 100)} % " f"belegt ({n.get('stufe')}).")) si = s.get("sicherungen") or {} if si.get("stufe") not in (None, "keine"): fehlend = ", ".join(f["name"] for f in si.get("fehlend") or []) zeilen.append(f"Sicherungen: {si.get('stufe')}" + (f", fehlend: {fehlend}" if fehlend else "") + ".") return zeilen def _laeuft() -> list[str]: zeilen = [] try: for j in _get("/api/jobs").get("jobs") or []: if j.get("state") in ("running", "queued"): zeilen.append(f"Läuft auf der KI-Box: {j.get('label')}.") except Fehler: pass try: for lauf in _get(f"{HL}/laeufe").get("laeufe") or []: if lauf.get("status") == "laeuft": zeilen.append(f"Läuft im Homelab: {lauf.get('name')} ({lauf.get('baustein')}), " f"zuletzt: {(lauf.get('schritte') or ['–'])[-1]}.") sl = (_get(f"{HL}/sammellauf") or {}).get("sammellauf") or {} if sl.get("status") == "laeuft": zeilen.append("Im Homelab läuft „Alle aktualisieren“.") except Fehler: pass return zeilen def updates() -> str: """Offene Updates je Gerät mit der Kennung für update_starten — und warum eines keinen Knopf hat.""" zeilen = [] for z in _ziele(): for b in _offen(z): was = b.get("kurz") or b.get("verfuegbar") or "neu" if b.get("aktion"): zeilen.append(f"{z['name']} (ziel={z['id']}, baustein={b['id']}): {b.get('name')} · {was} — startbar.") else: zeilen.append(f"{z['name']} ({z['id']}): {b.get('name')} · {was} — kein Knopf: " f"{b.get('grund') or 'wartet'}.") return "\n".join(zeilen) or "Keine offenen Updates, KI-Box und Homelab sind aktuell." def speicher() -> str: """Speicherpool, Systemplatte, NAS und Sicherungen samt Vorschlägen, was Platz bringt.""" s = _get(f"{HL}/speicher") zeilen = _speicher_zeilen(s) for e in s.get("empfehlungen") or []: platz = f" (~{round(e['platz'] / 1e9)} GB)" if e.get("platz") else "" zeilen.append(f"Vorschlag: {e.get('titel')}{platz}. {e.get('text')}") return "\n".join(zeilen) def protokoll(anzahl: int = 15) -> str: """Was im Homelab zuletzt geschah (Läufe, Meldungen, Hinweise), neueste zuerst.""" eintraege = _get(f"{HL}/protokoll", grenze=max(1, min(int(anzahl), 60)), berichte=0).get("eintraege") or [] zeilen = [] for e in eintraege: zeit = datetime.fromtimestamp(float(e.get("zeit") or 0), ORTSZEIT).strftime("%d.%m. %H:%M") zeilen.append(f"{zeit} {e.get('titel')}" + (f" — {e.get('text')}" if e.get("text") else "")) return "\n".join(zeilen) or "Das Protokoll ist leer." def radar() -> str: """Kandidaten des Modell-Radars: Status, ob „Jetzt testen“ geht, und warum nicht.""" zeilen = [] for k in _get("/api/radar").get("kandidaten") or []: teile = [f"{k.get('name')} (id={k.get('id')}, {k.get('rolle')}): {k.get('status')}"] if k.get("testbar_jetzt"): teile.append("jetzt testbar") elif k.get("testbar_grund"): teile.append(str(k["testbar_grund"])) zeilen.append(" — ".join(teile)) return "\n".join(zeilen) or "Das Radar hat gerade keine Kandidaten." # --- Handeln (nur auf Bitte des Commanders; der Grund wird protokolliert) -------------------------------------- def update_starten(ziel: str, baustein: str, grund: str) -> str: def tun() -> str: z = next((x for x in _ziele() if x.get("id") == ziel), None) if not z: raise Fehler(f"Das Gerät „{ziel}“ gibt es nicht. updates() zeigt die Kennungen.") b = next((x for x in z.get("bausteine") or [] if x.get("id") == baustein), None) if not b or not b.get("aktion"): raise Fehler(f"Für {z['name']} / {baustein} gibt es gerade keinen Update-Knopf" + (f": {b.get('grund')}" if b and b.get("grund") else ".")) antwort = _post(_homelab_pfad(b["aktion"]["pfad"])) hinweis = (" Ich bin dabei kurz nicht erreichbar." if baustein == "hermes" else "") return (f"Gestartet: {z['name']} · {b.get('name')}. Vorher wird gesichert, danach geprüft; das Ergebnis kommt als " f"Meldung.{hinweis}" + (f" (Lauf {antwort.get('lauf')})" if antwort.get("lauf") else "")) return _handeln("update_starten", {"ziel": ziel, "baustein": baustein}, grund, tun) def alle_aktualisieren(bereich: str, grund: str) -> str: def tun() -> str: if bereich == "homelab": _post(f"{HL}/alle") return ("„Alle aktualisieren“ im Homelab läuft: Gäste nacheinander, der Proxmox-Host zuletzt, ohne Neustart; " "bei Rot hält es an. Am Ende kommt eine Sammelmeldung.") if bereich == "ki-box": _post(KI_BOX_ALLE) return ("„Alle aktualisieren“ auf der KI-Box läuft (Motor, llama-swap, Hermes, Betriebssystem). Beim Hermes-" "Schritt bin ich kurz nicht erreichbar.") raise Fehler("bereich muss „homelab“ oder „ki-box“ sein.") return _handeln("alle_aktualisieren", {"bereich": bereich}, grund, tun) def nach_updates_suchen(grund: str) -> str: def tun() -> str: _post("/api/maintenance/check-updates") return "Die KI-Box sucht nach Updates; in ein, zwei Minuten zeigt updates() das Ergebnis." return _handeln("nach_updates_suchen", {}, grund, tun) def dienst_neustarten(dienst: str, grund: str) -> str: def tun() -> str: antwort = _post("/api/maintenance/restart", {"service": dienst}) return str(antwort.get("text") or f"{dienst} wird neu gestartet.") return _handeln("dienst_neustarten", {"dienst": dienst}, grund, tun) def kernel_aufraeumen(grund: str) -> str: def tun() -> str: _post(f"{HL}/ziele/pve/kernel-aufraeumen") return ("Alte Kernel werden entfernt (was Proxmox für entbehrlich hält; der laufende und der nächste bleiben). " "Das Ergebnis kommt als Meldung.") return _handeln("kernel_aufraeumen", {}, grund, tun) def host_neustarten(grund: str) -> str: def tun() -> str: _post(f"{HL}/ziele/pve/neustart") return ("Der Proxmox-Host startet neu. Alle Container und die Arcane-VM sind ein paar Minuten weg, auch AdGuard " "(DNS im Heimnetz). Die KI-Box prüft danach, ob alles wiederkommt.") return _handeln("host_neustarten", {}, grund, tun) def radar_testen(kandidat: str, grund: str) -> str: def tun() -> str: antwort = _post(f"/api/radar/{kandidat}/testen") return str(antwort.get("text") or "Der Test läuft; das Ergebnis kommt als Meldung.") return _handeln("radar_testen", {"kandidat": kandidat}, grund, tun) def radar_suchen(grund: str) -> str: def tun() -> str: antwort = _post("/api/radar/suche") return str(antwort.get("text") or "Das Radar sucht nach neuen Modellen.") return _handeln("radar_suchen", {}, grund, tun)