- 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>
157 lines
7.2 KiB
Python
157 lines
7.2 KiB
Python
"""Updates der KI-Box: Zwischenspeicher für den Start-Bildschirm und der Box-Adapter (Phase 2).
|
|
|
|
maintenance.updates() fragt GitHub, apt und git und braucht nach einem Neustart gern zehn Sekunden
|
|
und mehr. Der Start-Bildschirm wartet darauf nie: Er bekommt den letzten Stand (oder zunächst
|
|
nichts), frisch geholt wird im Hintergrund. Seit 24.09.2026 liegt das hier statt im Router, weil
|
|
auch der Box-Adapter (ki_box_ziel) davon liest.
|
|
|
|
ki_box_ziel() beschreibt die KI-Box im gemeinsamen Modell aus kern/ziele.py: vier Bausteine mit
|
|
Stand, Versionen und dem Knopf, der das jeweilige Update anstößt.
|
|
"""
|
|
|
|
import platform
|
|
import threading
|
|
import time
|
|
|
|
from kern.ziele import Aktion, BausteinStand, ZielStand
|
|
|
|
from services import maintenance, system, update_verlauf
|
|
|
|
_updates_lock = threading.Lock()
|
|
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1}
|
|
UPDATES_CACHE_S = 600
|
|
|
|
NAMEN = {"os": "Betriebssystem", "engine": "Motor (llama.cpp)", "swap": "llama-swap", "hermes": "Hermes"}
|
|
NAMEN_KURZ = {"os": "Betriebssystem", "engine": "Motor", "swap": "llama-swap", "hermes": "Hermes"}
|
|
|
|
AKTIONEN = {
|
|
"os": Aktion("POST", "/api/maintenance/os-update",
|
|
"Betriebssystem-Updates jetzt einspielen? Danach wird der Stack geprüft."),
|
|
"engine": Aktion("POST", "/api/maintenance/engine-update",
|
|
"Den Motor jetzt aktualisieren? Die Modelle sind dabei kurz weg; scheitert die Prüfung, "
|
|
"geht es zurück."),
|
|
"swap": Aktion("POST", "/api/maintenance/swap-update",
|
|
"llama-swap jetzt aktualisieren? Laufende Antworten brechen ab; scheitert die Prüfung, "
|
|
"geht es zurück."),
|
|
"hermes": Aktion("POST", "/api/maintenance/hermes-update",
|
|
"Hermes jetzt aktualisieren? Lucy ist dabei ein paar Minuten weg."),
|
|
}
|
|
|
|
|
|
def _holen() -> None:
|
|
stand = maintenance.update_stand
|
|
try:
|
|
daten = maintenance.updates()
|
|
except Exception:
|
|
daten = None
|
|
with _updates_lock:
|
|
if daten is not None or _updates_cache["daten"] is None:
|
|
_updates_cache["daten"] = daten or {}
|
|
_updates_cache["ts"] = time.time()
|
|
_updates_cache["stand"] = stand
|
|
_updates_cache["laeuft"] = False
|
|
|
|
|
|
def gecacht() -> dict:
|
|
"""Letzter Stand der Update-Prüfung; ist er veraltet, wird im Hintergrund neu geholt."""
|
|
with _updates_lock:
|
|
# Nach jedem abgeschlossenen Update zählt maintenance.update_stand hoch — dann sofort neu holen,
|
|
# statt bis zu 10 Minuten „Aktualisieren" für schon eingespielte Updates zu zeigen (24.09.2026).
|
|
veraltet = (_updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
|
|
or _updates_cache["stand"] != maintenance.update_stand)
|
|
if veraltet and not _updates_cache["laeuft"]:
|
|
_updates_cache["laeuft"] = True
|
|
threading.Thread(target=_holen, name="updates-holen", daemon=True).start()
|
|
return _updates_cache["daten"] or {}
|
|
|
|
|
|
def gelesen() -> bool:
|
|
"""Liegt schon ein Ergebnis vor (nach einem Neustart dauert die erste Prüfung)?"""
|
|
return _updates_cache["daten"] is not None
|
|
|
|
|
|
def bausteine(u: dict) -> list[dict]:
|
|
"""Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft."""
|
|
liste = []
|
|
if u.get("os"):
|
|
liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"})
|
|
if u.get("engine"):
|
|
liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"})
|
|
if u.get("swap"):
|
|
liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"})
|
|
for c in u.get("components") or []:
|
|
if c.get("key") == "hermes_agent" and c.get("update"):
|
|
liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"})
|
|
return liste
|
|
|
|
|
|
def unklar(u: dict) -> list[dict]:
|
|
"""Bausteine, deren Update-Prüfung scheiterte — ehrlich „unbekannt" statt „aktuell"."""
|
|
return [{"id": k, "name": NAMEN_KURZ.get(k, k), "grund": grund}
|
|
for k, grund in (u.get("pruef_fehler") or {}).items() if grund]
|
|
|
|
|
|
# --- Box-Adapter ------------------------------------------------------------------------
|
|
|
|
def _ubuntu() -> str | None:
|
|
try:
|
|
info = platform.freedesktop_os_release()
|
|
except OSError:
|
|
return None
|
|
return " ".join(x for x in (info.get("NAME"), info.get("VERSION_ID")) if x) or None
|
|
|
|
|
|
def _versionen(u: dict) -> dict[str, tuple[str | None, str | None]]:
|
|
"""(installiert, verfügbar) je Baustein. „Verfügbar" nur, wenn es Neues gibt — die Abfrage
|
|
dafür ist bei GitHub zwischengespeichert (maintenance._github_json, 15 Minuten)."""
|
|
engine = maintenance._installed_engine_build()
|
|
swap = maintenance._installed_swap_version()
|
|
werte: dict[str, tuple[str | None, str | None]] = {
|
|
"os": (_ubuntu(), f"{u['os']} Pakete" if u.get("os") else None),
|
|
"engine": (f"b{engine}" if engine else None, None),
|
|
"swap": (f"v{swap}" if swap else None, None),
|
|
}
|
|
try:
|
|
if u.get("engine") and (rel := maintenance._latest_engine_asset_release()):
|
|
werte["engine"] = (werte["engine"][0], str(rel.get("tag_name") or "") or None)
|
|
if u.get("swap"):
|
|
rel = maintenance._github_json(f"repos/{maintenance.SWAP_REPO}/releases/latest")
|
|
tag = str(rel.get("tag_name") or "") if isinstance(rel, dict) else ""
|
|
werte["swap"] = (werte["swap"][0], tag or None)
|
|
except Exception:
|
|
pass
|
|
hermes = next((c for c in u.get("components") or [] if c.get("key") == "hermes_agent"), {})
|
|
git = system.find_hermes_agent_git() or {}
|
|
version = maintenance._hermes_version(git["path"]) if git.get("path") else None
|
|
werte["hermes"] = (version or hermes.get("current"),
|
|
f"+{hermes.get('behind')} Änderungen" if hermes.get("update") else None)
|
|
return werte
|
|
|
|
|
|
def ki_box_ziel() -> ZielStand:
|
|
"""Die KI-Box als Ziel mit ihren vier Bausteinen."""
|
|
u = gecacht()
|
|
ziel = ZielStand(id="ki-box", name="KI-Box", art="ki-box", instanz="box", erreichbar=True,
|
|
geprueft=_updates_cache["ts"] or None,
|
|
rueckweg="Sicherung vor jedem Sonntags-Lauf; Motor, llama-swap und Hermes rollen "
|
|
"bei rotem Check selbst zurück, das Betriebssystem nicht.")
|
|
if not gelesen():
|
|
ziel.bausteine = [BausteinStand(id=k, name=n, zustand="wird-geprueft") for k, n in NAMEN.items()]
|
|
return ziel
|
|
offen = {b["id"]: b["neu"] for b in bausteine(u)}
|
|
fehler = {b["id"]: b["grund"] for b in unklar(u)}
|
|
fest = {p["baustein"]: p for p in update_verlauf.festgehalten()}
|
|
versionen = _versionen(u)
|
|
for k, name in NAMEN.items():
|
|
installiert, verfuegbar = versionen.get(k, (None, None))
|
|
stand = BausteinStand(id=k, name=name, zustand="aktuell", installiert=installiert, verfuegbar=verfuegbar)
|
|
if k in fest:
|
|
p = fest[k]
|
|
stand.zustand, stand.grund = "festgehalten", f"Seit {p['seit']} auf {p['version']}: {p['grund']}"
|
|
elif k in fehler:
|
|
stand.zustand, stand.grund = "unbekannt", fehler[k]
|
|
elif k in offen:
|
|
stand.zustand, stand.kurz, stand.aktion = "neu", offen[k], AKTIONEN[k]
|
|
ziel.bausteine.append(stand)
|
|
return ziel
|