phase2d: gemeinsames Ziel-Modell, KI-Box als erster Adapter, strukturierter Update-Verlauf

- 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>
This commit is contained in:
Hitonabi
2026-09-24 18:14:42 +02:00
co-authored by Claude Opus 5.5
parent 22bb8c672b
commit 589c14d5e9
12 changed files with 649 additions and 116 deletions
+54 -11
View File
@@ -14,8 +14,9 @@ from datetime import datetime
from pathlib import Path
import httpx
from kern.zeit import LOCAL_TZ
from services import jobengine, system
from services import jobengine, system, update_verlauf
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"}
@@ -261,6 +262,40 @@ def updates() -> dict:
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."""
@@ -582,12 +617,14 @@ def logs(service: str, lines: int = 200) -> dict:
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) -> dict:
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")
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')}."}
@@ -612,7 +649,7 @@ def os_update_job() -> dict:
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)
return _starten(["bash", "-c", cmd], "OS-Update (apt)", zeitlimit_s=90 * 60, bausteine=["os"])
def engine_update_job() -> dict | None:
@@ -622,7 +659,7 @@ def engine_update_job() -> dict | None:
# 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)
zeitlimit_s=60 * 60, bausteine=["engine"])
def swap_update_job() -> dict | None:
@@ -631,7 +668,7 @@ def swap_update_job() -> dict | 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)
zeitlimit_s=30 * 60, bausteine=["swap"])
def _hermes_update_cmd() -> str:
@@ -677,12 +714,13 @@ def _hermes_update_cmd() -> str:
f"exit $RC")
def hermes_update_job() -> dict:
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)."""
(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)
zeitlimit_s=45 * 60, bausteine=["hermes"] if verlauf else None)
def update_all_job() -> dict:
@@ -696,12 +734,16 @@ def update_all_job() -> dict:
"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():
@@ -716,6 +758,7 @@ def update_all_job() -> dict:
"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."}
@@ -727,7 +770,7 @@ def update_all_job() -> dict:
labels = " → ".join(label for label, _ in parts)
ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
zeitlimit_s=3 * 3600)
zeitlimit_s=3 * 3600, bausteine=bausteine)
if ergebnis.get("ok"):
ergebnis["parts"] = [label for label, _ in parts]
return ergebnis