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
+99 -12
View File
@@ -6,6 +6,11 @@ Was ein Update-Lauf wirklich gebracht hat, steht nicht im Hermes-Cron-Status —
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
@@ -19,10 +24,13 @@ 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
@@ -66,8 +74,14 @@ STUFE_DER_ZEILE = {"fehler": "rot", "zurueckgerollt": "gelb", "festgehalten": "g
"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 ----------------------------------------------------------------
@@ -130,6 +144,15 @@ def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
# 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":
@@ -143,11 +166,63 @@ def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
titel = "Neu gestartet"
else:
titel = "Alles aktuell"
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",
"stufe": stufe, "titel": titel, "zeilen": zeilen}
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]:
@@ -182,18 +257,30 @@ def _protokoll_ende() -> str:
return text
def laeufe(anzahl: int = 8) -> list[dict]:
"""Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn das Protokoll wuchs."""
def _schluessel(pfad: Path) -> tuple[int, int] | None:
try:
st = NOTIFY_LOG.stat()
schluessel = (st.st_mtime_ns, st.st_size)
st = pfad.stat()
except OSError:
return []
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())
_cache["laeufe"] = laeufe_aus(_protokoll_ende()) if schluessel else []
_cache["schluessel"] = schluessel
return _cache["laeufe"][:anzahl]
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) -----------------------