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:
co-authored by
Claude Opus 5.5
parent
22bb8c672b
commit
589c14d5e9
@@ -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) -----------------------
|
||||
|
||||
Reference in New Issue
Block a user