""" Wartung: Updates (OS/Engine/Modelle), Dienst-Neustart (system- vs user-aware), Reboot, Logs. Portiert/modernisiert aus Mission Control v1 (routers/maintenance.py). Passwortfrei über NOPASSWD-Whitelist (sudo -n). OS-Update/Reboot brauchen einmalig erweiterte sudoers (siehe docs/BEDIENUNG.md). Lange Ops laufen als jobengine-Job. """ import os import re import subprocess import time from datetime import datetime from pathlib import Path import httpx from kern.zeit import LOCAL_TZ from services import jobengine, system, update_verlauf # System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). SYSTEM_SERVICES = {"llama-swap"} # projekte-sync/mc2-backup sind Einmal-Dienste hinter Timern: „restart“ heißt dort „jetzt # erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py). USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console", "hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme", "projekte-sync", "mc2-backup", "mc2-radar", "mc2-morgenmeldung", "mc2-autoupdate"} # Warum eine Update-Prüfung nicht klappte (None = geprüft). Bis 24.09.2026 verschluckten die # Prüfungen Netz-, API- und apt-Fehler und meldeten „kein Update“ — das Cockpit zeigte dann # „aktuell“, obwohl gar nicht geprüft werden konnte. PRUEF_FEHLER: dict[str, str | None] = {"os": None, "engine": None, "swap": None, "hermes": None} # Zählt abgeschlossene Updates hoch; Zwischenspeicher (z. B. im Box-Wart-Start) vergleichen ihn, # damit „Aktualisieren“ direkt nach einem Update verschwindet statt erst nach 10 Minuten. update_stand = 0 # Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root). _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) ENGINE_UPDATE_CMD = os.environ.get( "MC_ENGINE_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-engine.sh") # Engine = offizieller Vulkan-Build von ggml-org/llama.cpp (RADV auf Strix Halo). ENGINE_PATH = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan") ENGINE_REPO = os.environ.get("MC_ENGINE_REPO", "ggml-org/llama.cpp") _engine_cache = {"ts": 0.0, "avail": False} # Neueste Release, die WIRKLICH ein Vulkan-x64-Binary trägt. Die allerneueste Release hat # manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check # als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release # nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert. _ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE) # GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte # bisher neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt. GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900")) _github_cache: dict[str, tuple[float, object]] = {} def _github_json(pfad: str, timeout: float = 8) -> object: jetzt = time.time() if (eintrag := _github_cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S: return eintrag[1] r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"}) r.raise_for_status() daten = r.json() _github_cache[pfad] = (jetzt, daten) return daten def _latest_engine_asset_release() -> dict | None: try: rels = _github_json(f"repos/{ENGINE_REPO}/releases?per_page=15") for rel in (rels if isinstance(rels, list) else []): if any(_ENGINE_ASSET_RX.search(a.get("name", "")) for a in (rel.get("assets") or [])): return rel except Exception: pass return None # Router = llama-swap (mostlygeek): proxyt Anfragen und wechselt die Modelle heiß. Eigenes # Upstream-Projekt mit eigenem Release-Zyklus → getrennt von der Engine geführt. SWAP_UPDATE_CMD = os.environ.get( "MC_SWAP_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-swap.sh") SWAP_BIN = os.environ.get("MC_SWAP_BIN", "/usr/local/bin/llama-swap") SWAP_REPO = os.environ.get("MC_SWAP_REPO", "mostlygeek/llama-swap") _swap_cache = {"ts": 0.0, "avail": False} # Stack-Funktionsprüfung NACH jedem Update (OS/Engine/Router): verifiziert per echter # Inferenz, dass der Stack noch läuft → Job wird rot, wenn ein Update etwas zerschossen hat. STACK_POSTCHECK = os.path.join(_REPO_ROOT, "deploy", "stack-postcheck.sh") def _installed_engine_build() -> int | None: """Build-Nummer der installierten llama-server-Binary (z.B. 9821), oder None. Vulkan-Build braucht LD_LIBRARY_PATH=ENGINE_PATH zum Start von --version.""" bin_path = os.path.join(ENGINE_PATH, "llama-server") if not os.path.exists(bin_path): return None try: env = dict(os.environ, LD_LIBRARY_PATH=ENGINE_PATH) out = subprocess.run([bin_path, "--version"], capture_output=True, text=True, timeout=20, env=env) txt = (out.stderr or "") + (out.stdout or "") # Formate je nach Build: "version: 0.1.0-dev (build 10426, ...)", "version: 9821 (hash)", "build: (9821)", "b9821". if (m := re.search(r"\bbuild\s+(\d{3,})\b", txt)) \ or (m := re.search(r"version:\s*(\d{3,})", txt)) \ or (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) \ or (m := re.search(r"\bb(\d{3,})\b", txt)): return int(m.group(1)) except Exception: return None return None def _os_upgradable() -> int: try: # LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt # grep auf einer deutschen Box ("[aktualisierbar von:]") nichts und meldet faelschlich 0. out = subprocess.run( ["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"], capture_output=True, text=True, timeout=10) PRUEF_FEHLER["os"] = None return int((out.stdout or "0").strip() or 0) except Exception as exc: PRUEF_FEHLER["os"] = f"Paketliste nicht lesbar ({exc.__class__.__name__})" return 0 def _engine_update_available() -> bool: now = time.time() if now - _engine_cache["ts"] < 3600: return _engine_cache["avail"] avail = False fehler = None try: rel = _latest_engine_asset_release() if rel is None: fehler = "Neueste Engine-Version bei GitHub nicht abrufbar" rel = rel or {} tag = str(rel.get("tag_name", "")) latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None installed = _installed_engine_build() if latest is not None and installed is not None: avail = latest > installed # präziser Build-Nummer-Vergleich elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp() avail = pub > os.path.getmtime(ENGINE_PATH) + 86400 elif fehler is None: fehler = "Installierte Engine-Version nicht lesbar" except Exception as exc: avail = False fehler = f"Engine-Prüfung gescheitert ({exc.__class__.__name__})" PRUEF_FEHLER["engine"] = fehler _engine_cache.update(ts=now, avail=avail) return avail def _installed_swap_version() -> int | None: """Versions-Nummer der installierten llama-swap-Binary (z.B. 228), oder None.""" if not os.path.exists(SWAP_BIN): return None try: out = subprocess.run([SWAP_BIN, "--version"], capture_output=True, text=True, timeout=15) txt = (out.stdout or "") + (out.stderr or "") if (m := re.search(r"version:\s*(\d+)", txt)) or (m := re.search(r"\bv?(\d{2,})\b", txt)): return int(m.group(1)) except Exception: return None return None def _swap_update_available() -> bool: now = time.time() if now - _swap_cache["ts"] < 3600: return _swap_cache["avail"] avail = False fehler = None try: rel = _github_json(f"repos/{SWAP_REPO}/releases/latest", timeout=6) tag = str(rel.get("tag_name", "")) if isinstance(rel, dict) else "" latest = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None installed = _installed_swap_version() if latest is not None and installed is not None: avail = latest > installed elif latest is None: fehler = "Neueste llama-swap-Version bei GitHub nicht abrufbar" else: fehler = "Installierte llama-swap-Version nicht lesbar" except Exception as exc: avail = False fehler = f"llama-swap-Prüfung gescheitert ({exc.__class__.__name__})" PRUEF_FEHLER["swap"] = fehler _swap_cache.update(ts=now, avail=avail) return avail _comp_cache = {"ts": 0.0, "data": []} def _hermes_agent_update() -> dict: """Hermes-Agent wird aus **git** aktualisiert (CLI `hermes update` = git pull origin ). Darum HEAD vs. origin/ prüfen (fetch + behind-count) — NICHT GitHub-Releases: die werden selten getaggt, main läuft ihnen voraus → sonst zeigt das UI nie ein Update an.""" info = {"key": "hermes_agent", "name": "Hermes Agent", "current": None, "latest": None, "update": False, "reachable": None} git = system.find_hermes_agent_git() if not git or not git.get("path"): return info path = git["path"] info["current"] = git.get("hash") try: branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, timeout=8).stdout.strip() or "main") fetch = subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch], capture_output=True, text=True, timeout=25) info["reachable"] = (fetch.returncode == 0) if fetch.returncode == 0: cnt = subprocess.run(["git", "-C", path, "rev-list", "--count", f"HEAD..origin/{branch}"], capture_output=True, text=True, timeout=8) behind = int(cnt.stdout.strip() or "0") if cnt.returncode == 0 else 0 info["behind"] = behind info["update"] = behind > 0 oh = subprocess.run(["git", "-C", path, "rev-parse", "--short", f"origin/{branch}"], capture_output=True, text=True, timeout=8).stdout.strip() info["latest"] = (f"{oh} ({behind} neu)" if behind else (oh or info["current"])) except Exception: info["reachable"] = False return info def _components_cached() -> list[dict]: """Update-Status von Hermes-Agent (1h-Cache → GitHub schonen).""" now = time.time() if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]: return _comp_cache["data"] data = [_hermes_agent_update()] PRUEF_FEHLER["hermes"] = ("Hermes-Quelle nicht erreichbar (git fetch)" if data[0].get("reachable") is False else None) _comp_cache.update(ts=now, data=data) return data def _last_apt_update() -> float | None: for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]: if os.path.exists(path): try: return os.path.getmtime(path) except Exception: pass return None def updates() -> dict: """Offene Updates je Baustein. `pruef_fehler` nennt je Baustein, warum nicht geprüft werden konnte (None = geprüft). Die frühere Modell-Upgrade-Empfehlung ist raus (24.09.2026): das Modell-Radar hat die Aufgabe übernommen, und die Oberfläche zeigte sie nicht mehr.""" daten = {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0, "swap": 1 if _swap_update_available() else 0, "last_check": _last_apt_update(), "components": _components_cached()} daten["pruef_fehler"] = dict(PRUEF_FEHLER) return daten # „════════ [2/3] Router (llama-swap) ════════" — so markiert update_all_job jeden Teil der Kette. _TEIL_MARKE = re.compile(r"^═+ \[(\d+)/(\d+)\] ") @jobengine.abschluss("wartung:verlauf") def _update_im_verlauf(job: dict) -> None: """Updates per Knopf landen im strukturierten Update-Verlauf (seit 24.09.2026). Vorher sah man sie dort gar nicht: Nur der Sonntags-Lauf schrieb Meldungen, aus denen der Verlauf gelesen wurde.""" bausteine = list((job.get("abschluss_daten") or {}).get("bausteine") or []) if not bausteine: return lauf = datetime.fromtimestamp(job["started_at"], LOCAL_TZ).isoformat(timespec="seconds") zustand, code = job.get("state"), job.get("returncode") # Scheitert „Alle aktualisieren“, zeigt die letzte Teil-Marke im Protokoll, wo die Kette stand. erreicht = len(bausteine) if zustand != "done" and len(bausteine) > 1: marken = [int(m.group(1)) for z in jobengine.protokoll(job["id"]) if (m := _TEIL_MARKE.match(z.strip()))] erreicht = marken[-1] if marken else 1 for nr, baustein in enumerate(bausteine, start=1): if zustand == "done" or nr < erreicht: ergebnis, text = "eingespielt", "Per Knopf eingespielt, Prüfung grün." elif nr > erreicht: ergebnis, text = "offen", "Nicht mehr gelaufen, weil ein Teil davor scheiterte." elif zustand == "canceled": ergebnis, text = "offen", "Abgebrochen." elif job.get("zeitlimit"): ergebnis, text = "fehler", "Zeitlimit überschritten." elif baustein in ("engine", "swap") and code == 1: ergebnis, text = "zurueckgerollt", "Prüfung rot, automatisch zurückgerollt." else: ergebnis, text = "fehler", f"Fehlgeschlagen (Exit {code}). Das Protokoll steht beim Auftrag." update_verlauf.eintragen(lauf, "Update von Hand", baustein, ergebnis, text) @jobengine.nacharbeit("wartung:nach_update") def _nach_update() -> None: """Nach einem abgeschlossenen Update: Zwischenspeicher leeren und den Stand hochzählen.""" global update_stand _engine_cache.update(ts=0.0, avail=False) _swap_cache.update(ts=0.0, avail=False) _comp_cache.update(ts=0.0, data=[]) update_stand += 1 # ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ── def _os_held_back() -> list[dict]: """Pakete, die apt aktuell NICHT einspielt, obwohl es sie gäbe — mit ehrlichem Grund. Zwei Fälle, aus `apt-get -s upgrade` (Simulation) gelesen: • Phasen-Rollout (Ubuntu staffelt Updates prozentual aus) → 'deferred due to phasing'. • zurückgehalten wegen neuer Abhängigkeiten → 'kept back'. Beides ist normal und löst sich von selbst — verhindert nur das 'hängt fest'-Gefühl, wenn nach 'Fertig' noch aktualisierbare Pakete übrig scheinen.""" held: list[dict] = [] sections = { "The following upgrades have been deferred due to phasing:": "phasing", "The following packages have been kept back:": "kept_back", } try: out = subprocess.run( ["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"], capture_output=True, text=True, timeout=25) reason: str | None = None for line in (out.stdout or "").splitlines(): hdr = sections.get(line.strip()) if hdr: # Abschnitts-Kopf → folgende Zeilen sammeln reason = hdr continue if reason and line.startswith((" ", "\t")): # eingerückt = Paketnamen des Abschnitts for name in line.split(): held.append({"name": name, "reason": reason}) elif reason: # nicht eingerückt → Abschnitt zu Ende reason = None except Exception: pass held.sort(key=lambda p: p["name"]) return held def os_update_details() -> dict: """Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat) + ehrliche Anzeige der vom System zurückgestellten Pakete (Phasen-Rollout / kept back).""" out_pkgs: list[dict] = [] try: # LC_ALL=C → englische Ausgabe, damit der Regex "[upgradable from: ...]" greift # (deutsche Box meldet sonst "[aktualisierbar von:]" und die Liste bliebe leer). out = subprocess.run(["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null"], capture_output=True, text=True, timeout=20) for line in (out.stdout or "").splitlines(): # Format: name/repo neue_version arch [upgradable from: alte_version] m = re.match(r"^([^/\s]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s*([^\]]+)\]", line.strip()) if m: out_pkgs.append({"name": m.group(1), "candidate": m.group(2), "current": m.group(3).strip()}) out_pkgs.sort(key=lambda p: p["name"]) return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "held_back": _os_held_back()} except Exception as exc: return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)} def engine_update_details() -> dict: """Installierte vs. neueste Engine-Build-Nummer + Release-Name/-Notizen/-Link.""" info: dict = {"kind": "engine", "installed_build": _installed_engine_build(), "latest_build": None, "latest_tag": None, "name": None, "url": None, "body": None} try: rel = _latest_engine_asset_release() or {} tag = str(rel.get("tag_name", "")) info["latest_tag"] = tag info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None info["name"] = rel.get("name") or tag info["url"] = rel.get("html_url") body = (rel.get("body") or "").strip() info["body"] = body[:2000] if body else None # Nur zusammenfassen, wenn wirklich ein neuerer Build ansteht (spart einen LLM-Call, # wenn das Modal bei aktuellem Stand geöffnet wird). if body and info["latest_build"] and info["installed_build"] \ and info["latest_build"] > info["installed_build"]: ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle " "Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).") info.update(_summarize_release("engine", tag, ctx, body[:6000])) except Exception as exc: info["error"] = str(exc) return info def swap_update_details() -> dict: """Installierte vs. neueste llama-swap-Version + Release-Name/-Notizen/-Link.""" info: dict = {"kind": "swap", "installed_build": _installed_swap_version(), "latest_build": None, "latest_tag": None, "name": None, "url": None, "body": None} try: rel = _github_json(f"repos/{SWAP_REPO}/releases/latest") tag = str(rel.get("tag_name", "")) info["latest_tag"] = tag info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None info["name"] = rel.get("name") or tag info["url"] = rel.get("html_url") body = (rel.get("body") or "").strip() info["body"] = body[:2000] if body else None if body and info["latest_build"] and info["installed_build"] \ and info["latest_build"] > info["installed_build"]: ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box " "verteilt und Sprachmodelle heiß nachlädt).") info.update(_summarize_release("swap", tag, ctx, body[:6000])) except Exception as exc: info["error"] = str(exc) return info # LLM-Zusammenfassung anstehender Updates in Lucys Stimme (die Box liest ihre Release-Notes # selbst). Jede Zusammenfassung beginnt mit einem klaren Aktions-Verdikt für den Besitzer # (kein Entwickler): "Musst du etwas tun? NEIN — das Fangnetz regelt das / JA: …". # Gecacht je Komponente auf den neuesten Commit-Hash/Release-Tag — das Modal darf beliebig # oft geöffnet werden, ohne das Modell jedes Mal neu zu befragen. _relnotes_caches: dict = {"hermes": {}, "engine": {}, "swap": {}} # Erste Zeile jeder Modell-Antwort: "AKTION: NEIN" oder "AKTION: JA — ". # Toleriert Markdown-Deko (**fett**, Bullet, Überschrift), die 'fast' gern einstreut. _ACTION_RX = re.compile( r"^[\s*_>#\-]*AKTION:?\s*\**\s*(JA|NEIN)\b[\s—:,.\-*_]*(.*?)[\s*_]*$", re.IGNORECASE) def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict: """Fasst Release-Notes/Commits in Lucys Stimme zusammen und trennt das Aktions-Verdikt ab. Rückgabe: {summary, action_needed, action_text}. Cache je Komponente auf `key`.""" empty = {"summary": "", "action_needed": None, "action_text": ""} if not key: return empty cache = _relnotes_caches.setdefault(kind, {}) if cache.get("key") == key and cache.get("data"): return cache["data"] from config import LLAMA_SWAP_URL prompt = ( "Du bist Lucy, die Stimme einer lokalen AI-Box, und erklärst dem Besitzer (KEIN Entwickler, " "fasst nie eine Konsole an) ein anstehendes Update ruhig und verständlich.\n" f"{context}\n\n" "Anstehende Änderungen:\n" + changes + "\n\n" "Antworte auf DEUTSCH, knapp und klar. HALTE DICH GENAU an dieses Format:\n" "Zeile 1 ist das Aktions-Verdikt und beginnt mit 'AKTION: ':\n" " • 'AKTION: NEIN' — wenn der Besitzer nichts tun muss (die Box spielt es selbst ein, das " "Fangnetz prüft danach automatisch und rollt bei Problemen von allein zurück). Das ist der " "Normalfall.\n" " • 'AKTION: JA — ' — NUR wenn er wirklich selbst " "handeln muss.\n" "Danach maximal 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Breaking Changes oder " "geänderte/entfernte Config-Schlüssel ZUERST und mit '⚠️' markiert, dann was unser Setup " "betrifft, dann lohnende neue Features. Keine Einleitung, keine Überschrift." ) try: r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={ "model": "fast", "max_tokens": 550, "temperature": 0.2, "chat_template_kwargs": {"enable_thinking": False}, "messages": [{"role": "user", "content": prompt}], }) r.raise_for_status() resp = r.json() choice = (resp.get("choices") or [{}])[0] text = ((choice.get("message") or {}).get("content") or "").strip() finish_reason = choice.get("finish_reason") or "" # finish_reason == "length" → Antwort wurde wegen Token-Limits abgeschnitten → # letzten (unvollständigen) Stichpunkt entfernen. Bei "stop"/None → vollständig. if finish_reason == "length" and text: head, _, _tail = text.rpartition("\n") text = head if head else "" # Aktions-Verdikt herauslösen (strukturiertes Feld fürs UI). Normalerweise Zeile 1, # aber tolerant: erste passende Zeile suchen (Modell startet manchmal mit Leerzeile). action_needed: bool | None = None action_text = "" lines = text.splitlines() for idx, ln in enumerate(lines): if m := _ACTION_RX.match(ln): action_needed = m.group(1).upper() == "JA" action_text = (m.group(2) or "").strip() text = "\n".join(lines[:idx] + lines[idx + 1:]).strip() break data = {"summary": text, "action_needed": action_needed, "action_text": action_text} if text: cache.update(key=key, data=data) return data except Exception as exc: return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})", "action_needed": None, "action_text": ""} def _summarize_hermes_commits(commits: list[dict]) -> dict: subjects = "\n".join(f"- {c['subject']}" for c in commits[:100]) context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf " "Strix Halo; genutzt werden: api_server/Gateway, Telegram, eigenes Gedächtnis, " "terminal-/web-Tools, Plugins, cron).") return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects) def _hermes_version(path: str) -> str | None: """Versionsnummer aus der pyproject.toml des Hermes-Checkouts (z. B. „0.21.3“).""" try: text = (Path(path) / "pyproject.toml").read_text(encoding="utf-8") except OSError: return None m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) return m.group(1) if m else None def hermes_update_details() -> dict: """Commits, die ein Hermes-Update einspielen würde (HEAD..origin/) + LLM-Zusammenfassung.""" info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []} git = system.find_hermes_agent_git() if not git or not git.get("path"): info["error"] = "Hermes-Agent-Repo nicht gefunden." return info path = git["path"] # Vor dem Abruf: scheitert der (Zeitlimit, Netz), zeigt die Seite trotzdem die laufende Version. info["installed_version"] = _hermes_version(path) try: branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, timeout=8).stdout.strip() or "main") info["branch"] = branch subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch], capture_output=True, text=True, timeout=25) log = subprocess.run(["git", "-C", path, "log", "--pretty=format:%h\x1f%s\x1f%cr", f"HEAD..origin/{branch}"], capture_output=True, text=True, timeout=10) commits = [] for line in (log.stdout or "").splitlines(): parts = line.split("\x1f") if len(parts) == 3: commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]}) info["commits"] = commits info["behind"] = len(commits) if commits: info.update(_summarize_hermes_commits(commits)) except Exception as exc: info["error"] = str(exc) return info def update_details(kind: str) -> dict: return {"os": os_update_details, "engine": engine_update_details, "swap": swap_update_details, "hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})() def _run(cmd: list[str]) -> dict: """Befehl ausfuehren. sudo laeuft IMMER mit `-n` (never prompt). v3-Umbau P1 (28.08.2026): Frueher konnte hier ein Sudo-Passwort durchgereicht und per `-S` an stdin gefuettert werden — das Passwort kam aus dem `localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Auf der Box gemessen: `sudo -n true` laeuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt. Der ganze Pfad war also totes Risiko ohne Gegenwert. `-n` heisst: Braucht sudo je doch ein Passwort, scheitert der Befehl SOFORT und sauber, statt auf eine Eingabe zu warten, die es hier nicht gibt. Das meldet die Funktion darunter als `password_required` — ein ehrliches Konfigurations-Signal, das man auf der Box loest und nicht mit einem im Browser geparkten Geheimnis uebertuencht.""" actual_cmd = list(cmd) if cmd and cmd[0] == "sudo": if "-S" in actual_cmd: actual_cmd = [x for x in actual_cmd if x != "-S"] if "-n" not in actual_cmd: actual_cmd.insert(1, "-n") try: p = subprocess.run(actual_cmd, capture_output=True, text=True, timeout=120) err_msg = p.stderr or "" if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg): return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "sudo verlangt hier ein Passwort. Das ist eine Konfigurations-Frage " "auf der Box (sudoers), nichts, was die Oberflaeche liefern kann."} return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]} except Exception as exc: return {"ok": False, "out": "", "err": str(exc)} def check_sudo_needs_password() -> dict | None: """Laeuft sudo hier passwortlos? Fehler-Dict wenn nein, sonst None.""" res = _run(["sudo", "true"]) if not res["ok"]: return res return None def restart_service(name: str) -> dict: if name in SYSTEM_SERVICES: if err := check_sudo_needs_password(): return err return _run(["sudo", "systemctl", "restart", name]) if name in USER_SERVICES: return _run(["systemctl", "--user", "restart", name]) return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."} def logs(service: str, lines: int = 200) -> dict: lines = max(1, min(lines, 1000)) if service in USER_SERVICES: r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"]) return {"ok": r["ok"], "text": r["out"] or r["err"]} if service in SYSTEM_SERVICES: # Journal-Lesen braucht i.d.R. KEIN sudo (User ist in Gruppe adm/systemd-journal). # Erst ohne sudo versuchen; nur bei fehlenden Rechten auf sudo zurückfallen. r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"]) if r["ok"]: return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"} if err := check_sudo_needs_password(): return err r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"]) return {"ok": r["ok"], "text": r["out"] or r["err"]} return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."} def _starten(args: list[str], label: str, zeitlimit_s: int | None = None, bausteine: list[str] | None = None) -> dict: """Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Job gleichzeitig. Prüfen und Eintragen passieren unter einer Sperre (jobengine.start_job_exklusiv) — vorher lag zwischen Prüfung und Start ein langsamer Aufruf, und zwei Klicks konnten zwei Updates starten.""" job_id, laeuft = jobengine.start_job_exklusiv( "maintenance", args, label, zeitlimit_s=zeitlimit_s, nacharbeit="wartung:nach_update", abschluss="wartung:verlauf" if bausteine else None, abschluss_daten={"bausteine": bausteine or []}) if job_id is None: return {"ok": False, "status": "busy", "running": (laeuft or {}).get("label"), "detail": f"Es läuft schon: {(laeuft or {}).get('label', 'ein Update')}."} return {"ok": True, "job_id": job_id} def check_updates_job() -> dict: if err := check_sudo_needs_password(): return err # apt-get update gehört in den Wartungs-Riegel: parallel zu einem apt upgrade kollidiert es mit # der dpkg-Sperre. In der Gruppe taucht der Job auch in der Oberfläche auf. return _starten(["bash", "-c", "sudo apt-get update"], "Nach Updates suchen", zeitlimit_s=15 * 60) def os_update_job() -> dict: if err := check_sudo_needs_password(): return err # Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging). # DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) — # sonst lehnt sudos env-Policy die Variable ggf. ab und das Upgrade bricht ab. cmd = ("sudo apt-get update && " "sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' " f"&& bash {STACK_POSTCHECK}") return _starten(["bash", "-c", cmd], "OS-Update (apt)", zeitlimit_s=90 * 60, bausteine=["os"]) def engine_update_job() -> dict | None: if not ENGINE_UPDATE_CMD: return None # update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den # Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem # neuen Build. KEIN sudo-Passwort-Gate: das Skript ist per sudoers NOPASSWD freigegeben. return _starten(["bash", "-c", ENGINE_UPDATE_CMD], "Engine-Update (llama.cpp Vulkan)", zeitlimit_s=60 * 60, bausteine=["engine"]) def swap_update_job() -> dict | None: if not SWAP_UPDATE_CMD: return None # update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack # und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer Version. return _starten(["bash", "-c", SWAP_UPDATE_CMD], "Router-Update (llama-swap)", zeitlimit_s=30 * 60, bausteine=["swap"]) def _hermes_update_cmd() -> str: """Die komplette Hermes-Update-Befehlskette (Backup → Update → Doctor → UI-Build → Neustarts → Postcheck) — geteilt von hermes_update_job und update_all_job.""" git = system.find_hermes_agent_git() path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent") py = os.path.join(path, "venv", "bin", "python") backup = os.path.join(_REPO_ROOT, "deploy", "backup.sh") postcheck = os.path.join(_REPO_ROOT, "deploy", "hermes-postcheck.sh") # Backup → Stop UI (verhindert Crash durch Löschen der Sourcen) → update → doctor → # UI-Build mit MC2-Basepath (/hermes-ui/) → Gateway + UI-Neustart → Smoke-Test. hui_web = os.path.join(path, "web") hui_dist = os.path.join(path, "hermes_cli", "web_dist") npm_path = os.path.expanduser("~/.hermes/node/bin") build_cmd = ( f"if [ -d {hui_web} ]; then " f"cd {hui_web} && PATH={npm_path}:$PATH npx --no-install vite build --base=/hermes-ui/ --outDir /tmp/h-build --emptyOutDir " f"&& rm -rf {hui_dist} && cp -r /tmp/h-build {hui_dist}; fi" ) # EHRLICHER Exit-Code (Lehre 08.07.): Die Dienste müssen auch bei einem GESCHEITERTEN # Update wieder hochkommen (die UI wurde ja gestoppt) — aber der Job darf dann nicht # grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0, # Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem # verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden. # --no-gateway-restart (17.09.2026): `hermes update` startet das Gateway sonst selbst neu und # endet mit Exit 1, wenn sein Fleet-Check danach keine Zeilen sieht (#93406) — unter systemd # bei uns der Normalfall. Die &&-Kette brach ab (kein doctor, kein /hermes-ui/-Build), der # Job war rot, autoupdate.sh rollte zurück und pinnte — bei GRÜNEM Gehirn-Check (06./17.09.). # Der Neustart passiert unten ohnehin; Richter bleibt der Postcheck. return ("RC=0; " f"bash {backup} || true; " f"systemctl --user stop hermes-builtin-ui || true; " f"{{ cd {path} && {py} -m hermes_cli.main update --yes --no-gateway-restart " f"&& {py} -m hermes_cli.main doctor " f"&& {build_cmd}; }} || RC=1; " f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; " f"systemctl --user restart hermes-gateway hermes-builtin-ui || RC=1; " f"sleep 6; bash {postcheck} || RC=1; " f"if [ $RC -ne 0 ]; then echo '✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)'; fi; " f"exit $RC") def hermes_update_job(verlauf: bool = True) -> dict: """Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo (alles im User-Space). Läuft als Hintergrund-Job (kann einige Minuten dauern). verlauf=False: autoupdate.sh startet den Job und trägt das Ergebnis selbst in seinen Lauf ein.""" return _starten(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update", zeitlimit_s=45 * 60, bausteine=["hermes"] if verlauf else None) def update_all_job() -> dict: """„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job — Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift; es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem.""" if laeuft := jobengine.active_in_group("maintenance"): return {"ok": False, "status": "busy", "running": laeuft.get("label"), "detail": f"Es läuft schon: {laeuft.get('label', 'ein Update')}."} upd = updates() parts: list[tuple[str, str]] = [] bausteine: list[str] = [] if upd.get("engine") and ENGINE_UPDATE_CMD: parts.append(("Engine (llama.cpp)", ENGINE_UPDATE_CMD)) bausteine.append("engine") if upd.get("swap") and SWAP_UPDATE_CMD: parts.append(("Router (llama-swap)", SWAP_UPDATE_CMD)) bausteine.append("swap") if any(c.get("update") is True for c in upd.get("components") or []): parts.append(("Hermes-Agent", _hermes_update_cmd())) bausteine.append("hermes") os_pending = (upd.get("os") or 0) > 0 if os_pending: if err := check_sudo_needs_password(): # sudo verlangt wider Erwarten ein Passwort (sudoers geaendert?): OS auslassen # statt alles zu blockieren — aber nur, wenn es sudo-freie Teile gibt; sonst # den Fehler ehrlich durchreichen. if not parts: return err os_pending = False else: parts.append(("OS (apt)", ( "sudo apt-get update && " "sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' " f"&& bash {STACK_POSTCHECK}"))) bausteine.append("os") if not parts: return {"ok": False, "status": "nothing", "detail": "Keine Updates ausstehend."} cmd = " && ".join( f"(echo; echo '════════ [{i + 1}/{len(parts)}] {label} ════════'; {c})" for i, (label, c) in enumerate(parts)) if not os_pending: cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte labels = " → ".join(label for label, _ in parts) ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})", zeitlimit_s=3 * 3600, bausteine=bausteine) if ergebnis.get("ok"): ergebnis["parts"] = [label for label, _ in parts] return ergebnis