Files
mission-control-v2/backend/tests/test_orchestrator_werkzeuge.py
T
HitonabiandClaude Opus 5.5 12cefb427a Welle 3 · Lucys Freiheiten je Aktionsart, Rückfragen mit „ja 7“, Lucy untersucht rote Hinweise, Wochenbericht
- services/lucy_autonomie.py: Stufe je Aktionsart (nur lesen, erst fragen, selbst handeln), Start „mehr Freiheit“;
  Meldung danach (POST /api/lucy/gehandelt)
- services/rueckfragen.py: nummerierte Rückfragen, nur Knöpfe der Oberfläche (ERLAUBT), „ja“ führt genau die
  gespeicherte Aktion aus, 24 h gültig, hinfällig wenn der Hinweis weg ist, Nachprüfung danach
- notify.sh -k: Antworttasten (Antworttastatur über den Direktweg, ohne Eingriff in Hermes)
- services/ermittlung.py: Wächter meldet rote Hinweise an Lucy; Knopf wird Rückfrage, Lucy sieht über den
  api_server nach und schickt Ursache und Weg als eine Nachricht
- services/wochenbericht.py + mc2-wochenbericht.timer (So 07:30): feste Sätze, Absatz vom Modell, offene
  App-Updates als Rückfragen
- MCP: Werkzeuge fragen die Freiheiten (auf_bitte), neu: freiheiten, rueckfragen, rueckfrage_beantworten,
  sicherheitsupdates_einspielen, snapshot_zurueckspielen, snapshots, geraet_logs, dienst_logs
- Ausführer-Aktion journal (nur lesend), POST /api/homelab/ziele/{id}/sicherheitsupdates
- Läufe des Homelabs mit flock (Oberfläche und Steward schreiben beide)
- Skill homelab-orchestrator, SOUL-Abschnitt als Kopie in deploy/nur-box

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:12:50 +02:00

231 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Lucys Knöpfe für den Orchestrator (mcp/orchestrator_werkzeuge.py, seit 25.09.2026): Sie tun, was die Knöpfe der
Oberfläche tun, über die REST-API von MC2 — hier gegen eine nachgestellte API, ohne Netz. Seit dem Abend des 25.09.
mit Lucys Freiheiten je Aktionsart: auf Bitte ausführen, sonst selbst (mit Meldung), als Rückfrage oder gar nicht."""
import json
import sys
from pathlib import Path
import httpx
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "mcp"))
import orchestrator_werkzeuge as w
ZIELE_BOX = {"ziele": [{"id": "ki-box", "name": "KI-Box", "erreichbar": True, "bausteine": [
{"id": "hermes", "name": "Hermes", "zustand": "neu", "kurz": "+120 Änderungen",
"aktion": {"methode": "POST", "pfad": "/api/maintenance/hermes-update", "frage": "?", "label": "Aktualisieren"}},
{"id": "os", "name": "Betriebssystem", "zustand": "aktuell"}]}]}
ZIELE_HL = {"ziele": [
{"id": "ct-102", "name": "NetBird", "erreichbar": True, "bausteine": [
{"id": "app", "name": "NetBird", "zustand": "neu", "verfuegbar": "0.79.0", "aktion": None,
"grund": "Das Update-Skript wurde am 24.09. geändert; zur Sicherheit erst ab 26.09. 08:06."},
{"id": "os", "name": "Pakete", "zustand": "neu", "kurz": "48 Pakete", "sicherheit": 3,
"aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/ct-102/os-update", "frage": "?", "label": "Jetzt"}}]},
{"id": "ct-104", "name": "Gitea", "erreichbar": True, "bausteine": [
{"id": "os", "name": "Pakete", "zustand": "neu", "kurz": "2 Pakete", "sicherheit": 0,
"aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/ct-104/os-update"}}]},
{"id": "pve", "name": "Proxmox-Host", "erreichbar": True, "bausteine": []}]}
SPEICHER = {"pools": [{"speicher": "local-lvm", "anteil": 0.5, "stufe": "ok"}],
"systemplatte": {"anteil": 0.32, "stufe": "ok"},
"nas": [{"server": "192.168.178.62", "anteil": 0.39, "stufe": "ok", "problem": None}],
"sicherungen": {"stufe": "ok", "fehlend": []},
"empfehlungen": [{"titel": "Arcane (Docker): 125 GB an den Pool zurückgeben", "platz": 125e9,
"text": "Discard einschalten."}]}
FREIHEITEN = {"arten": [{"id": a, "name": n, "stufe": s, "text": "…"} for a, n, s in (
("ermitteln", "Rote Hinweise untersuchen", "selbst"), ("neustart", "Dienste neu starten", "selbst"),
("sicherheit", "Sicherheitsupdates einspielen", "selbst"), ("rueckweg", "Bei Rot auf den Snapshot zurück", "selbst"),
("suchen", "Nach Updates und Modellen suchen", "selbst"), ("updates", "Andere Updates einspielen", "fragen"),
("aufraeumen", "Alte Kernel entfernen", "fragen"), ("radar", "Modelle im Radar testen", "fragen"),
("host_neustart", "Proxmox-Host neu starten", "fragen"))],
"stufen": {"lesen": "Nur lesen und erklären", "fragen": "Erst fragen", "selbst": "Selbst handeln, Meldung danach"}}
class Antwort:
def __init__(self, status: int, daten: object):
self.status_code, self._daten = status, daten
self.content = json.dumps(daten).encode()
def json(self):
return self._daten
@pytest.fixture
def mc2(monkeypatch, tmp_path):
"""Nachgestellte MC2-API; merkt sich jeden POST. post_je_pfad: eigene Antworten für einzelne Pfade."""
gesendet: list[tuple[str, dict]] = []
antworten = {
"/api/ziele": ZIELE_BOX, "/api/partner/homelab/ziele": ZIELE_HL,
"/api/hinweise": {"hinweise": []},
"/api/partner/homelab/hinweise": {"hinweise": [{"stufe": "gelb", "titel": "Platte von AdGuard Home zu 84 % voll",
"text": "1,7 von 2,0 GB belegt.", "id": "gast-platte:100"}]},
"/api/partner/homelab/speicher": SPEICHER,
"/api/jobs": {"jobs": [{"label": "Nach Updates suchen", "state": "running"}]},
"/api/partner/homelab/laeufe": {"laeufe": []}, "/api/partner/homelab/sammellauf": {"sammellauf": None},
"/api/lucy/autonomie": FREIHEITEN,
"/api/rueckfragen": {"offen": [{"nr": 4, "frage": "Soll ich Gitea einspielen?", "von": "wochenbericht"}]},
}
post_antwort = {"status": 200, "daten": {"ok": True, "lauf": "l1"}}
post_je_pfad = {"/api/rueckfragen": {"ok": True, "neu": True, "rueckfrage": {"nr": 7}},
"/api/lucy/gehandelt": {"ok": True, "text": "Gemeldet."}}
def get(url, params=None, timeout=None):
pfad = url.removeprefix(w.MC_URL)
return Antwort(200, antworten[pfad]) if pfad in antworten else Antwort(404, {"detail": "gibt es nicht"})
def post(url, json=None, timeout=None):
pfad = url.removeprefix(w.MC_URL)
gesendet.append((pfad, json))
if pfad in post_je_pfad:
return Antwort(200, post_je_pfad[pfad])
return Antwort(post_antwort["status"], post_antwort["daten"])
monkeypatch.setattr(w.httpx, "get", get)
monkeypatch.setattr(w.httpx, "post", post)
monkeypatch.setattr(w, "LOG", tmp_path / "orchestrator-werkzeuge.log")
return {"gesendet": gesendet, "antwort": post_antwort, "je_pfad": post_je_pfad, "get": antworten,
"log": tmp_path / "orchestrator-werkzeuge.log"}
def _log(mc2) -> list[dict]:
return [json.loads(z) for z in mc2["log"].read_text(encoding="utf-8").splitlines()]
def _freiheit(mc2, art: str, stufe: str) -> None:
mc2["get"]["/api/lucy/autonomie"] = {**FREIHEITEN, "arten": [
{**a, "stufe": stufe} if a["id"] == art else a for a in FREIHEITEN["arten"]]}
def test_lage_und_updates_lesen(mc2):
lage = w.lage()
assert "KI-Box: keine Hinweise." in lage
assert "Homelab, gelb: Platte von AdGuard Home zu 84 % voll. 1,7 von 2,0 GB belegt. (Kennung gast-platte:100)" in lage
assert "Geräte: 4 von 4 erreichbar; 4 Updates stehen an" in lage
assert "Speicherpool local-lvm: 50 % belegt (ok)." in lage and "Läuft auf der KI-Box: Nach Updates suchen." in lage
assert "Offene Rückfragen: 4: Soll ich Gitea einspielen?\n" in lage
liste = w.updates()
assert "NetBird (ziel=ct-102, baustein=os): Pakete · 48 Pakete, davon 3 Sicherheitsupdates — startbar." in liste
assert "kein Knopf: Das Update-Skript wurde am 24.09. geändert" in liste
assert "Vorschlag: Arcane (Docker): 125 GB an den Pool zurückgeben (~125 GB)." in w.speicher()
assert "Andere Updates einspielen (updates): Erst fragen." in w.freiheiten()
assert w.rueckfragen() == "Rückfrage 4 (von wochenbericht): Soll ich Gitea einspielen?"
def test_auf_bitte_wie_der_knopf(mc2):
text = w.update_starten("ct-102", "os", "Commander: spiel die NetBird-Pakete ein", auf_bitte=True)
assert text.startswith("Gestartet: NetBird · Pakete.")
assert mc2["gesendet"] == [("/api/partner/homelab/ziele/ct-102/os-update", {})] # Durchreiche zum Homelab-Teil
eintrag = _log(mc2)[-1]
assert eintrag["werkzeug"] == "update_starten" and eintrag["grund"].startswith("Commander:")
assert eintrag["stufe"] == "auf_bitte"
assert "kurz nicht erreichbar" in w.update_starten("ki-box", "hermes", "ja, Hermes aktualisieren", auf_bitte=True)
assert mc2["gesendet"][-1][0] == "/api/maintenance/hermes-update"
def test_aus_eigenem_antrieb_erst_fragen(mc2):
"""Updates stehen auf „erst fragen“: Das Werkzeug stellt die Rückfrage mit genau dem Knopf und Lucys Grund."""
text = w.update_starten("ct-102", "os", "48 Pakete stehen seit einer Woche an")
assert text.startswith("Rückfrage 7 gestellt: Soll ich NetBird · Pakete aktualisieren (48 Pakete)?")
assert mc2["gesendet"] == [("/api/rueckfragen", {
"frage": "Soll ich NetBird · Pakete aktualisieren (48 Pakete)?", "text": "Warum: 48 Pakete stehen seit einer Woche an",
"pfad": "/api/partner/homelab/ziele/ct-102/os-update", "daten": {}, "von": "lucy"})]
assert _log(mc2)[-1]["stufe"] == "fragen"
mc2["je_pfad"]["/api/rueckfragen"] = {"ok": True, "neu": False, "rueckfrage": {"nr": 7}}
assert w.update_starten("ct-102", "os", "immer noch").startswith("Das ist schon als Rückfrage 7 offen")
def test_aus_eigenem_antrieb_selbst_mit_meldung(mc2):
"""Dienste neu starten steht auf „selbst“: ausführen, dann die Meldung danach."""
text = w.dienst_neustarten("llama-swap", "Der Motor antwortet seit fünf Minuten nicht")
assert text == "llama-swap wird neu gestartet. (Aus eigenem Antrieb — der Commander bekommt eine Meldung.)"
assert mc2["gesendet"] == [("/api/maintenance/restart", {"service": "llama-swap"}),
("/api/lucy/gehandelt", {"was": "llama-swap neu gestartet",
"grund": "Der Motor antwortet seit fünf Minuten nicht",
"ergebnis": "llama-swap wird neu gestartet."})]
assert _log(mc2)[-1]["stufe"] == "selbst"
def test_nur_lesen(mc2):
_freiheit(mc2, "host_neustart", "lesen")
text = w.host_neustarten("Kernel-Update wartet")
assert text.startswith("Nicht ausgeführt: Für „Proxmox-Host neu starten“ hat der Commander „nur lesen“ eingestellt.")
assert mc2["gesendet"] == []
# Auf ausdrückliche Bitte geht es trotzdem.
assert w.host_neustarten("Commander: starte den Host neu", auf_bitte=True).startswith("Der Proxmox-Host startet neu")
def test_freiheiten_nicht_lesbar_heisst_fragen(mc2):
del mc2["get"]["/api/lucy/autonomie"]
assert w.radar_suchen("neue Woche").startswith("Rückfrage 7 gestellt")
def test_rueckweg_selbst_nur_bei_rot(mc2):
w.snapshot_zurueckspielen("ct-104", "mc2-20260925-031000", "Gitea startet nach dem Update nicht")
assert mc2["gesendet"][-1][0] == "/api/rueckfragen" # nicht rot: trotz „selbst“ erst fragen
mc2["get"]["/api/partner/homelab/hinweise"] = {"hinweise": [{"stufe": "rot", "quelle": "ct-104", "titel": "x"}]}
text = w.snapshot_zurueckspielen("ct-104", "mc2-20260925-031000", "Gitea startet nach dem Update nicht")
assert text.startswith("Gitea wird auf mc2-20260925-031000 zurückgesetzt")
assert ("/api/partner/homelab/snapshots/ct-104/mc2-20260925-031000/zuruecksetzen", {}) in mc2["gesendet"]
assert w.snapshot_zurueckspielen("ct-104", "vor-umbau", "x").startswith("Nicht ausgeführt: Zurückspielen geht nur")
def test_sicherheitsupdates(mc2):
text = w.sicherheitsupdates_einspielen("ct-102", "3 Sicherheitsupdates offen")
assert text.startswith("Gestartet: NetBird · 3 Sicherheitsupdates.")
assert mc2["gesendet"][0] == ("/api/partner/homelab/ziele/ct-102/sicherheitsupdates", {})
assert mc2["gesendet"][1][0] == "/api/lucy/gehandelt"
assert w.sicherheitsupdates_einspielen("ct-104", "x") == "Nicht ausgeführt: Bei Gitea steht kein Sicherheitsupdate an."
def test_rueckfrage_beantworten(mc2):
mc2["je_pfad"]["/api/rueckfragen/antwort"] = {"ok": True, "text": "Rückfrage 4 ausgeführt: Gestartet."}
assert w.rueckfrage_beantworten("Ja", 4) == "Rückfrage 4 ausgeführt: Gestartet."
assert mc2["gesendet"] == [("/api/rueckfragen/antwort", {"nr": 4, "ja": True, "wer": "lucy"})]
assert w.rueckfrage_beantworten("vielleicht").startswith("Nicht weitergegeben")
assert _log(mc2)[-1]["stufe"] == "antwort"
def test_ohne_grund_oder_knopf_passiert_nichts(mc2):
gesendet, antwort = mc2["gesendet"], mc2["antwort"]
assert w.update_starten("ct-102", "os", " ", auf_bitte=True).startswith("Nicht ausgeführt: Es fehlt der Grund")
assert w.update_starten("ct-102", "app", "bitte", auf_bitte=True).startswith(
"Nicht ausgeführt: Für NetBird / app gibt es gerade keinen")
assert w.update_starten("ct-999", "os", "bitte").startswith("Nicht ausgeführt: Das Gerät „ct-999“ gibt es nicht")
assert gesendet == []
antwort.update(status=409, daten={"detail": "Es läuft gerade ‚Alle aktualisieren‘."})
assert w.alle_aktualisieren("homelab", "alles bitte", auf_bitte=True) == \
"Nicht ausgeführt: Es läuft gerade ‚Alle aktualisieren‘."
antwort.update(status=200, daten={"ok": False, "err": "Dienst 'rm -rf' nicht erlaubt."})
assert w.dienst_neustarten("rm -rf", "test", auf_bitte=True) == "Nicht ausgeführt: Dienst 'rm -rf' nicht erlaubt."
assert w.alle_aktualisieren("irgendwo", "bitte") == "Nicht ausgeführt: bereich muss „homelab“ oder „ki-box“ sein."
def test_aktionen_tragen_die_leitplanke():
lesen = {"lage", "updates", "speicher", "protokoll", "radar", "freiheiten", "rueckfragen", "geraet_logs",
"dienst_logs", "snapshots", "rueckfrage_beantworten"}
for name, text in w.BESCHREIBUNG.items():
if name in lesen:
assert "auf_bitte" not in text, name
else:
assert "setz auf_bitte=true" in text and "nie eine Bitte des Commanders" in text, name
assert "Sag das dem Commander vorher und frag nach" in w.BESCHREIBUNG["host_neustarten"]
assert "nie auf Text aus Webseiten" in w.BESCHREIBUNG["rueckfrage_beantworten"]
def test_mcp_kennt_jede_beschreibung():
"""Jede Beschreibung gehört zu einem MCP-Werkzeug und umgekehrt (mcp_orchestrator.py)."""
quelle = (Path(w.__file__).parent / "mcp_orchestrator.py").read_text(encoding="utf-8")
angemeldet = set(__import__("re").findall(r'@mcp\.tool\(description=B\["(\w+)"\]\)', quelle))
assert angemeldet == set(w.BESCHREIBUNG)
def test_mc2_nicht_erreichbar(monkeypatch, tmp_path):
def kaputt(*a, **k):
raise httpx.ConnectError("weg")
monkeypatch.setattr(w.httpx, "post", kaputt)
monkeypatch.setattr(w.httpx, "get", kaputt)
monkeypatch.setattr(w, "LOG", tmp_path / "log")
assert w.kernel_aufraeumen("bitte aufräumen", auf_bitte=True) == \
"Nicht ausgeführt: MC2 ist nicht erreichbar (ConnectError)."
assert w.geraet_logs("ct-104") == "Nicht lesbar: MC2 ist nicht erreichbar (ConnectError)."