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
+73
View File
@@ -1,7 +1,12 @@
"""Update-Verlauf aus dem Meldeprotokoll und festgehaltene Bausteine (Box-Wart 09/2026)."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from services import update_verlauf
# Echte Formate aus ~/mc2-notify.log der Box (gekürzt), inklusive fremder Meldungen dazwischen.
@@ -107,12 +112,50 @@ def test_laeufe_liest_die_datei_und_merkt_sich_den_stand(tmp_path, monkeypatch):
datei = tmp_path / "mc2-notify.log"
datei.write_text(PROTOKOLL, encoding="utf-8")
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", datei)
monkeypatch.setattr(update_verlauf, "VERLAUF_FILE", tmp_path / "kein-verlauf.jsonl")
monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []})
monkeypatch.setattr(update_verlauf, "_struktur_cache", {"schluessel": None, "laeufe": []})
assert len(update_verlauf.laeufe(3)) == 3
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", tmp_path / "gibt-es-nicht.log")
assert update_verlauf.laeufe() == []
def test_strukturierter_verlauf_loest_das_protokoll_ab(tmp_path, monkeypatch):
"""Ab dem ersten strukturierten Eintrag gilt er; derselbe Sonntag aus dem Protokoll fällt weg."""
protokoll = tmp_path / "mc2-notify.log"
protokoll.write_text(PROTOKOLL + (
"2026-10-04 04:31:00 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:\n"
"• Router (llama-swap): 256 → 257 eingespielt, Stack-Check grün.\n"), encoding="utf-8")
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", protokoll)
monkeypatch.setattr(update_verlauf, "VERLAUF_FILE", tmp_path / "verlauf.jsonl")
monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []})
monkeypatch.setattr(update_verlauf, "_struktur_cache", {"schluessel": None, "laeufe": []})
lauf = "2026-10-04T04:30:00+02:00"
update_verlauf.eintragen(lauf, "Updates am Sonntag", "swap", "eingespielt", "256 → 257 eingespielt, Stack-Check grün.")
update_verlauf.eintragen(lauf, "Updates am Sonntag", "engine", "zurueckgerollt",
"11200 fehlgeschlagen → zurückgerollt auf 11172 und festgehalten.")
update_verlauf.eintragen(lauf, "Updates am Sonntag", "hermes", "aktuell", "aktuell.")
with pytest.raises(ValueError):
update_verlauf.eintragen(lauf, "x", "swap", "super", "gibt es nicht")
laeufe = update_verlauf.laeufe()
assert laeufe[0]["start"] == lauf and laeufe[0]["anlass"] == "Updates am Sonntag"
assert (laeufe[0]["stufe"], laeufe[0]["titel"]) == ("gelb", "Zurückgerollt")
assert [(z["baustein"], z["ergebnis"]) for z in laeufe[0]["zeilen"]] == [
("llama-swap", "eingespielt"), ("Motor", "zurueckgerollt"), ("Hermes", "aktuell")]
# Der gleiche Sonntag aus dem Protokoll (04:31) taucht nicht doppelt auf, ältere Läufe bleiben.
assert [lauf["start"][:10] for lauf in laeufe[:3]] == ["2026-10-04", "2026-09-27", "2026-09-20"]
def test_kaputte_zeilen_im_verlauf_stoeren_nicht():
text = ('kein json\n{"lauf": "keine zeit", "ergebnis": "aktuell"}\n'
'{"lauf": "2026-10-04T04:30:00+02:00", "baustein": "os", "ergebnis": "unbekannt", "text": "x"}\n'
'{"lauf": "2026-10-04T04:30:00+02:00", "baustein": "os", "ergebnis": "eingespielt", "text": "10 Pakete."}\n')
laeufe = update_verlauf.strukturierte_laeufe(text)
assert len(laeufe) == 1 and [z["baustein"] for z in laeufe[0]["zeilen"]] == ["Betriebssystem"]
def test_festgehalten_und_freigeben(tmp_path, monkeypatch):
pins = tmp_path / "mc2-pins.json"
pins.write_text(json.dumps({
@@ -154,3 +197,33 @@ def test_zweitweg_zaehlt_wie_telegram():
"Stack-Check grün, alles läuft.\n")
laeufe = update_verlauf.laeufe_aus(text)
assert [(z["baustein"], z["ergebnis"]) for z in laeufe[0]["zeilen"]] == [("llama-swap", "eingespielt")]
@pytest.mark.skipif(not (shutil.which("bash") and shutil.which("jq")), reason="braucht bash und jq (auf der Box da)")
def test_autoupdate_schreibt_was_der_verlauf_liest(tmp_path):
"""Vertrag zwischen deploy/autoupdate.sh (Schreiber) und diesem Modul (Leser): Die Helfer
verlauf() und ergebnis() werden aus dem echten Skript geholt und in einer bash ausgeführt."""
skript = (Path(__file__).resolve().parents[2] / "deploy" / "autoupdate.sh").read_text(encoding="utf-8")
anfang = skript.index("VERLAUF=")
ende = skript.index("# ── Pin-Register")
helfer = skript[anfang:ende]
ziel = tmp_path / "verlauf.jsonl"
probe = helfer + (
'SUMMARY=()\n'
'name="Router (llama-swap)"; comp=swap; installed=256; latest=257\n'
'ergebnis "$comp" "$name" eingespielt "$installed → $latest eingespielt, Stack-Check grün."\n'
'ergebnis hermes "Hermes-Agent" zurueckgerollt "Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf abc."\n'
'verlauf neustart neustart "Die Box startet jetzt neu (linux-image)."\n'
'printf "%s\n" "${SUMMARY[@]}"\n')
lauf = subprocess.run(["bash", "-c", probe], capture_output=True, text=True, timeout=30,
env={**os.environ, "MC_UPDATE_VERLAUF": str(ziel), "MC_UPDATE_ANLASS": "Updates am Sonntag"})
assert lauf.returncode == 0, lauf.stderr
# Die Abschlussmeldung bleibt wie vorher („Name: Text“).
assert lauf.stdout.splitlines()[0] == "Router (llama-swap): 256 → 257 eingespielt, Stack-Check grün."
laeufe = update_verlauf.strukturierte_laeufe(ziel.read_text(encoding="utf-8"))
assert len(laeufe) == 1 and laeufe[0]["anlass"] == "Updates am Sonntag"
assert [(z["baustein"], z["ergebnis"], z["text"]) for z in laeufe[0]["zeilen"]] == [
("llama-swap", "eingespielt", "256 → 257 eingespielt, Stack-Check grün."),
("Hermes", "zurueckgerollt", "Update fehlgeschlagen → zurückgerollt und festgehalten auf abc."),
("Neustart", "neustart", "Die Box startet jetzt neu (linux-image).")]
assert laeufe[0]["titel"] == "Zurückgerollt"