- kern/ziele.py: Ziel -> Bausteine mit Stand (neu/aktuell/unbekannt/festgehalten/wird-geprueft), Versionen und dem Knopf, der das Update anstoesst; gleiches Modell fuer Box und Homelab - services/box_updates.py: Update-Zwischenspeicher aus dem Router geholt, ki_box_ziel() als Box-Adapter; GET /api/ziele - Update-Verlauf strukturiert: autoupdate.sh und die Update-Knoepfe schreiben je Baustein eine JSON-Zeile (mc2-update-verlauf.jsonl); die Meldungstexte bleiben gleich, aeltere Laeufe kommen weiter aus dem Meldeprotokoll. Updates per Knopf erscheinen jetzt auch im Verlauf. - jobengine: Abschluss-Haken fuer jedes Ende (neben der Nacharbeit fuer den Erfolg) - Sonntags-Lauf setzt seinen Anlass; Hermes-Job aus dem Lauf schreibt nicht doppelt - Vertragstest Bash-Schreiber gegen Python-Leser (laeuft auf der Box mit jq) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
777 lines
38 KiB
Python
777 lines
38 KiB
Python
"""
|
|
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: <hash> (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 <branch>).
|
|
Darum HEAD vs. origin/<branch> 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 — <grund>".
|
|
# 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 — <was er konkret tun/entscheiden muss>' — 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/<branch>) + 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
|