Files
mission-control-v2/backend/services/update_verlauf.py
T
HitonabiandClaude Opus 5.5 589c14d5e9 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>
2026-09-24 18:14:42 +02:00

324 lines
15 KiB
Python

"""
Update-Verlauf und festgehaltene Bausteine (Box-Wart, Umbau 09/2026).
Was ein Update-Lauf wirklich gebracht hat, steht nicht im Hermes-Cron-Status — der sagt nur
„Skript lief". Die Wahrheit ist das, was deploy/autoupdate.sh am Ende meldet: eine Zeile je
Baustein („255 → 256 eingespielt", „zurückgerollt + GEPINNT" …). notify.sh schreibt jede
Meldung mit Zeitstempel in ~/mc2-notify.log; der Verlauf wird von dort gelesen, nur lesend.
Seit 24.09.2026 gibt es dazu einen strukturierten Verlauf (mc2-update-verlauf.jsonl im Datenordner):
autoupdate.sh und die Update-Knöpfe schreiben je Baustein eine JSON-Zeile mit Lauf, Anlass, Ergebnis
und Text. Ab dem ersten solchen Eintrag gilt nur noch er; ältere Läufe kommen weiter aus dem
Meldeprotokoll. So bricht keine Umformulierung einer Telegram-Meldung mehr den Verlauf.
Das Pin-Register (/srv/models/mc2-pins.json) führt autoupdate.sh: Besteht ein Update den Check
nicht, wird zurückgerollt und der Baustein festgehalten — künftige Sonntage überspringen ihn.
Vom 06. bis 17.09.2026 hat das niemand gesehen, die Box bekam elf Tage keine Updates. Darum
zeigt der Wächter jeden festgehaltenen Baustein als Hinweis, und hier lässt er sich freigeben.
"""
import json
import os
import re
import threading
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json"))
VERLAUF_FILE = Path(os.environ.get("MC_UPDATE_VERLAUF",
str(einstellungen().daten_dir / "mc2-update-verlauf.jsonl")))
LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate
LAUF_LUECKE_S = 45 * 60 # Meldungen mit größerem Abstand gehören zu verschiedenen Läufen
NAMEN = {"swap": "llama-swap", "engine": "Motor (llama.cpp)", "hermes": "Hermes"}
FREIGEGEBEN_TEXT = "Freigegeben. Der nächste Sonntags-Lauf versucht das Update erneut."
# So heißen die Bausteine in den Meldungen von autoupdate.sh → kurzer Name fürs Cockpit.
_BAUSTEIN_IM_TEXT = (
(re.compile(r"Router \(llama-swap\)"), "llama-swap"),
(re.compile(r"Engine \(llama\.cpp\)"), "Motor"),
(re.compile(r"Hermes(-Agent)?\b"), "Hermes"),
)
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
_EINTRAG = re.compile(
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
r"(?:OK telegram(?: direkt)?|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
_UPDATE_MELDUNG = re.compile(
r"^(KRITISCH: )?(Router \(llama-swap\)|Engine \(llama\.cpp\)|Hermes-Agent|Hermes)[ -](aktualisiert|Update)"
r"|^Auto-Update abgebrochen|^Sonntags-Update")
_NEUSTART = re.compile(r"^Die Box startet jetzt neu|^Neustart (NICHT|verschoben|FEHLGESCHLAGEN)")
# Die Morgenmeldung (07:00) wiederholt die Nachtmeldungen gesammelt — sie ist kein eigener Lauf.
_MORGENMELDUNG = re.compile(r"^Guten Morgen, Commander\. Heute Nacht")
# Einordnung einer Ergebniszeile. Reihenfolge zählt: „zurückgerollt + GEPINNT“ ist ein Rückbau,
# „Check fehlgeschlagen“ ist offen und kein Stack-Schaden.
_EINORDNUNG = (
("fehler", re.compile(r"kritisch", re.IGNORECASE)),
("zurueckgerollt", re.compile(r"zurückgerollt", re.IGNORECASE)),
("festgehalten", re.compile(r"gepinnt", re.IGNORECASE)),
("offen", re.compile(r"wartet|unklar|check fehlgeschlagen|job-start|nicht ausgefuehrt|verschoben",
re.IGNORECASE)),
("fehler", re.compile(r"fehlgeschlagen|abgebrochen|ausgefallen|mit fehler", re.IGNORECASE)),
("neustart", re.compile(r"startet jetzt neu", re.IGNORECASE)),
("eingespielt", re.compile(r"eingespielt|aktualisiert|repariert", re.IGNORECASE)),
("aktuell", re.compile(r"aktuell", re.IGNORECASE)),
)
STUFE_DER_ZEILE = {"fehler": "rot", "zurueckgerollt": "gelb", "festgehalten": "gelb", "offen": "gelb",
"neustart": "info", "eingespielt": "gruen", "aktuell": "grau"}
_RANG = {"rot": 4, "gelb": 3, "gruen": 2, "info": 1, "grau": 0}
# Bausteine im strukturierten Verlauf → Name im Cockpit.
BAUSTEIN_NAME = {"swap": "llama-swap", "engine": "Motor", "hermes": "Hermes", "os": "Betriebssystem",
"neustart": "Neustart", "lauf": "Update-Lauf"}
_cache_lock = threading.Lock()
_cache: dict = {"schluessel": None, "laeufe": []}
_struktur_cache: dict = {"schluessel": None, "laeufe": []}
_schreib_lock = threading.Lock()
# --- Protokoll lesen ----------------------------------------------------------------
def eintraege(text: str) -> list[tuple[datetime, str]]:
"""Protokoll → (Zeitpunkt, Nachricht). Zeilen ohne Zeitstempel gehören zur Nachricht davor
(mehrzeilige Meldungen wie die Wochen-Zusammenfassung oder die Nachrichten-Übersicht)."""
ergebnis: list[tuple[datetime, str]] = []
for zeile in text.splitlines():
if m := _EINTRAG.match(zeile):
try:
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ)
except ValueError:
continue
ergebnis.append((zeit, m.group(2)))
elif ergebnis:
zeit, bisher = ergebnis[-1]
ergebnis[-1] = (zeit, f"{bisher}\n{zeile}")
return ergebnis
def _einordnen(text: str) -> str:
return next((art for art, muster in _EINORDNUNG if muster.search(text)), "offen")
def _baustein_aus(text: str) -> str:
return next((name for muster, name in _BAUSTEIN_IM_TEXT if muster.search(text)), "Update-Lauf")
def _lesbar(text: str) -> str:
"""Meldungstext fürs Cockpit: Fachwörter der Skripte in Alltagssprache."""
text = re.sub(r"\s*\+\s*GEPINNT", " und festgehalten", text)
text = re.sub(r"gepinnt, übersprungen", "festgehalten, übersprungen", text, flags=re.IGNORECASE)
text = text.replace("GEPINNT", "festgehalten").replace("FEHLGESCHLAGEN", "fehlgeschlagen")
text = re.sub(r"\b1 Commits\b", "1 Änderung", text)
text = re.sub(r"\b(\d+) Commits\b", r"\1 Änderungen", text)
# Nur der erste Satz: der Rest der Meldungen ist Begleittext für Telegram.
return re.split(r"(?<=[.!])\s", text.strip(), maxsplit=1)[0][:240]
def _zeile(baustein: str, text: str) -> dict:
ergebnis = _einordnen(text)
return {"baustein": baustein, "ergebnis": ergebnis, "stufe": STUFE_DER_ZEILE[ergebnis], "text": _lesbar(text)}
def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
zusammenfassung = next((t for _, t in reversed(gruppe) if _ZUSAMMENFASSUNG.search(t)), None)
zeilen: list[dict] = []
if zusammenfassung:
for z in zusammenfassung.splitlines()[1:]:
if m := re.match(r"^\s*•\s*(.+?):\s*(.+)$", z):
zeilen.append(_zeile(_baustein_aus(m.group(1)), m.group(2)))
for _, text in gruppe:
erste = text.splitlines()[0] if text else ""
if _NEUSTART.search(erste):
zeilen.append(_zeile("Neustart", erste))
elif erste.startswith(("Sonntags-Update", "Auto-Update abgebrochen")):
zeilen.append(_zeile("Update-Lauf", erste))
elif not zusammenfassung and _UPDATE_MELDUNG.search(erste):
# Ohne Abschlussmeldung (Lauf abgebrochen) sind die Einzelmeldungen alles, was es gibt.
zeilen.append(_zeile(_baustein_aus(erste), re.sub(r"^KRITISCH:\s*", "Kritisch: ", erste)))
start, ende = gruppe[0][0], gruppe[-1][0]
sonntag = start.weekday() == 6 and 4 <= start.hour < 6
return {"start": start.isoformat(), "ende": ende.isoformat(),
"anlass": "Updates am Sonntag" if sonntag else "Update von Hand",
**_bewertung(zeilen), "zeilen": zeilen}
def _bewertung(zeilen: list[dict]) -> dict:
"""Stufe und Titel eines Laufs aus seinen Zeilen: das Schlimmste zählt."""
stufe = max((z["stufe"] for z in zeilen), key=lambda s: _RANG[s], default="grau")
arten = {z["ergebnis"] for z in zeilen}
if stufe == "rot":
titel = "Fehler"
elif stufe == "gelb":
titel = ("Zurückgerollt" if "zurueckgerollt" in arten
else "Festgehalten" if "festgehalten" in arten else "Unvollständig")
elif stufe == "gruen":
titel = "Eingespielt"
elif stufe == "info":
titel = "Neu gestartet"
else:
titel = "Alles aktuell"
return {"stufe": stufe, "titel": titel}
# --- Strukturierter Verlauf (seit 24.09.2026) ----------------------------------------
def eintragen(lauf: str, anlass: str, baustein: str, ergebnis: str, text: str) -> None:
"""Eine Zeile in den strukturierten Verlauf (dasselbe Format schreibt autoupdate.sh)."""
if ergebnis not in STUFE_DER_ZEILE:
raise ValueError(f"Unbekanntes Ergebnis: {ergebnis}")
zeile = json.dumps({"lauf": lauf, "anlass": anlass, "ts": datetime.now(LOCAL_TZ).isoformat(timespec="seconds"),
"baustein": baustein, "ergebnis": ergebnis, "text": text}, ensure_ascii=False)
with _schreib_lock:
VERLAUF_FILE.parent.mkdir(parents=True, exist_ok=True)
with VERLAUF_FILE.open("a", encoding="utf-8", newline="\n") as f:
f.write(zeile + "\n")
def _zeitpunkt(wert: str) -> datetime | None:
try:
zeit = datetime.fromisoformat(wert)
except (TypeError, ValueError):
return None
return zeit if zeit.tzinfo else zeit.replace(tzinfo=LOCAL_TZ)
def strukturierte_laeufe(text: str) -> list[dict]:
"""Läufe aus dem strukturierten Verlauf (eine JSON-Zeile je Baustein), neueste zuerst."""
gruppen: dict[str, list[dict]] = {}
for zeile in text.splitlines():
try:
eintrag = json.loads(zeile)
except ValueError:
continue
if isinstance(eintrag, dict) and _zeitpunkt(str(eintrag.get("lauf"))):
gruppen.setdefault(str(eintrag["lauf"]), []).append(eintrag)
laeufe = []
for lauf, eintraege in gruppen.items():
zeilen = [{"baustein": BAUSTEIN_NAME.get(str(e.get("baustein")), str(e.get("baustein") or "Update-Lauf")),
"ergebnis": e["ergebnis"], "stufe": STUFE_DER_ZEILE[e["ergebnis"]],
"text": _lesbar(str(e.get("text") or ""))}
for e in eintraege if e.get("ergebnis") in STUFE_DER_ZEILE]
start = _zeitpunkt(lauf)
zeiten = [t for e in eintraege if (t := _zeitpunkt(str(e.get("ts"))))]
ende = max([start, *zeiten])
laeufe.append({"start": start.isoformat(), "ende": ende.isoformat(),
"anlass": str(eintraege[0].get("anlass") or "Update von Hand"),
**_bewertung(zeilen), "zeilen": zeilen})
return sorted(laeufe, key=lambda lauf: _zeitpunkt(lauf["start"]), reverse=True)
def zusammenfuehren(struktur: list[dict], alt: list[dict]) -> list[dict]:
"""Ab dem ersten strukturierten Lauf gilt nur noch der strukturierte Verlauf; davor das
Meldeprotokoll (dort stünde derselbe Sonntag sonst doppelt)."""
if struktur:
grenze = min(_zeitpunkt(lauf["start"]) for lauf in struktur)
alt = [lauf for lauf in alt if _zeitpunkt(lauf["start"]) < grenze]
return sorted(struktur + alt, key=lambda lauf: _zeitpunkt(lauf["start"]), reverse=True)
def laeufe_aus(text: str) -> list[dict]:
"""Alle Update-Läufe im Protokolltext, neueste zuerst."""
relevant = [(z, t) for z, t in eintraege(text)
if not _MORGENMELDUNG.match(t)
and (_ZUSAMMENFASSUNG.search(t) or _UPDATE_MELDUNG.search(t.splitlines()[0] if t else "")
or _NEUSTART.search(t.splitlines()[0] if t else ""))]
gruppen: list[list[tuple[datetime, str]]] = []
for eintrag in relevant:
if gruppen and (eintrag[0] - gruppen[-1][-1][0]).total_seconds() <= LAUF_LUECKE_S:
gruppen[-1].append(eintrag)
else:
gruppen.append([eintrag])
# Ein Neustart allein ist kein Update-Lauf (z. B. von Hand ausgelöst).
laeufe = [_lauf(g) for g in gruppen if any(not _NEUSTART.search(t.splitlines()[0]) for _, t in g)]
return list(reversed(laeufe))
def _protokoll_ende() -> str:
try:
with NOTIFY_LOG.open("rb") as f:
f.seek(0, os.SEEK_END)
groesse = f.tell()
f.seek(max(0, groesse - LESE_MAX))
roh = f.read()
except OSError:
return ""
text = roh.decode("utf-8", errors="replace")
if groesse > LESE_MAX:
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
return text
def _schluessel(pfad: Path) -> tuple[int, int] | None:
try:
st = pfad.stat()
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
def laeufe(anzahl: int = 8) -> list[dict]:
"""Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn eine Quelle wuchs."""
with _cache_lock:
schluessel = _schluessel(NOTIFY_LOG)
if _cache["schluessel"] != schluessel:
_cache["laeufe"] = laeufe_aus(_protokoll_ende()) if schluessel else []
_cache["schluessel"] = schluessel
schluessel = _schluessel(VERLAUF_FILE)
if _struktur_cache["schluessel"] != schluessel:
try:
text = VERLAUF_FILE.read_text(encoding="utf-8") if schluessel else ""
except OSError:
text = ""
_struktur_cache["laeufe"] = strukturierte_laeufe(text)
_struktur_cache["schluessel"] = schluessel
return zusammenfuehren(_struktur_cache["laeufe"], _cache["laeufe"])[:anzahl]
# --- Festgehaltene Bausteine (Pin-Register von autoupdate.sh) -----------------------
def _pins_lesen() -> dict:
try:
daten = json.loads(PINS_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return daten if isinstance(daten, dict) else {}
def festgehalten() -> list[dict]:
"""Bausteine, die der Sonntags-Lauf nach einem gescheiterten Update überspringt."""
ergebnis = []
for baustein, eintrag in _pins_lesen().items():
if not (isinstance(eintrag, dict) and eintrag.get("pinned")):
continue
datum = str(eintrag.get("datum") or "")
m = re.fullmatch(r"\d{4}-(\d{2})-(\d{2})", datum)
seit = f"{m.group(2)}.{m.group(1)}." if m else (datum or "?")
ergebnis.append({"baustein": baustein, "name": NAMEN.get(baustein, baustein),
"version": str(eintrag.get("version") or "?"), "seit": seit,
"grund": _lesbar(str(eintrag.get("grund") or ""))})
return ergebnis
def freigeben(baustein: str) -> bool:
"""Pin lösen (atomar schreiben). False, wenn der Baustein gar nicht festgehalten war."""
daten = _pins_lesen()
if baustein not in daten:
return False
daten.pop(baustein)
tmp = PINS_FILE.with_suffix(".tmp")
try:
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, PINS_FILE)
except OSError:
return False
return True