"""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. PROTOKOLL = """\ 2026-08-14 18:53:58 OK telegram: Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. 2026-08-23 04:31:15 QUEUED für Morgen-Digest: KRITISCH: Engine (llama.cpp)-Update auf 10588 UND Rollback fehlgeschlagen — Stack möglicherweise kaputt. 2026-08-23 07:04:17 OK telegram: 📰 DAILY NEWS 23.08.2026 • **Eine Nachricht** (Quelle, 21. August 2026) Text der Nachricht. 2026-09-04 14:07:27 OK telegram: Router (llama-swap) aktualisiert: 252 → 253. Stack-Check grün, alles läuft. 2026-09-04 14:09:30 OK telegram: Commander, die Wochenpflege der Box ist durch — kurz für dich: • Router (llama-swap): 252 → 253 eingespielt, Stack-Check grün. • Engine (llama.cpp): 10733 → 10797 eingespielt, Stack-Check grün. • Hermes-Agent: 1 Commits eingespielt, Gehirn-Check grün. 2026-09-04 14:26:29 OK telegram: Die Box startet jetzt neu — Kernel 7.0.0-31 ist eingespielt. 2026-09-06 04:31:16 QUEUED für Morgen-Digest: Engine (llama.cpp) aktualisiert: 10797 → 10819. Stack-Check grün, alles läuft. 2026-09-06 04:55:53 QUEUED für Morgen-Digest: Hermes-Update fehlgeschlagen (Gehirn-Check rot). Automatisch zurückgerollt auf 63279301b — läuft wieder. Hermes ist jetzt GEPINNT. 2026-09-06 04:55:53 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich: • Router (llama-swap): aktuell (253). • Engine (llama.cpp): 10797 → 10819 eingespielt, Stack-Check grün. • Hermes-Agent: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf 63279301b. 2026-09-10 12:00:00 OK telegram: Die Box startet jetzt neu — von Hand. 2026-09-13 04:31:07 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich: • Router (llama-swap): 253 → 255 eingespielt, Stack-Check grün. • Engine (llama.cpp): 10936 FEHLGESCHLAGEN → zurückgerollt auf 10819 + GEPINNT. • Hermes-Agent: gepinnt, übersprungen. 2026-09-13 04:31:07 QUEUED für Morgen-Digest: Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 ). Sie ist ein paar Minuten weg. 2026-09-20 04:30:39 QUEUED für Morgen-Digest: Router (llama-swap) aktualisiert: 255 → 256. Stack-Check grün, alles läuft. 2026-09-20 04:35:33 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich: • Router (llama-swap): 255 → 256 eingespielt, Stack-Check grün. • Engine (llama.cpp): 11026 → 11057 eingespielt, Stack-Check grün. • Hermes-Agent: 1861 Commits eingespielt, Gehirn-Check grün. 2026-09-27 04:31:00 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich: • Router (llama-swap): aktuell (256). • Engine (llama.cpp): aktuell (11057). • Hermes-Agent: aktuell. """ def test_laeufe_neueste_zuerst_und_ohne_fremde_meldungen(): laeufe = update_verlauf.laeufe_aus(PROTOKOLL) # 27.09., 20.09., 13.09., 06.09., 04.09., 23.08. — der Neustart vom 10.09. allein ist kein Lauf. assert [lauf["start"][:10] for lauf in laeufe] == [ "2026-09-27", "2026-09-20", "2026-09-13", "2026-09-06", "2026-09-04", "2026-08-23"] def test_gruener_sonntag_mit_einer_zeile_je_baustein(): lauf = update_verlauf.laeufe_aus(PROTOKOLL)[1] assert lauf["anlass"] == "Updates am Sonntag" assert (lauf["stufe"], lauf["titel"]) == ("gruen", "Eingespielt") assert [(z["baustein"], z["ergebnis"]) for z in lauf["zeilen"]] == [ ("llama-swap", "eingespielt"), ("Motor", "eingespielt"), ("Hermes", "eingespielt")] assert lauf["zeilen"][2]["text"] == "1861 Änderungen eingespielt, Gehirn-Check grün." def test_rueckbau_und_festhalten_werden_gelb_und_lesbar(): lauf = update_verlauf.laeufe_aus(PROTOKOLL)[2] # 13.09. assert (lauf["stufe"], lauf["titel"]) == ("gelb", "Zurückgerollt") zeilen = {z["baustein"]: z for z in lauf["zeilen"]} assert zeilen["Motor"]["ergebnis"] == "zurueckgerollt" assert zeilen["Motor"]["text"] == "10936 fehlgeschlagen → zurückgerollt auf 10819 und festgehalten." assert zeilen["Hermes"]["ergebnis"] == "festgehalten" assert zeilen["Neustart"]["ergebnis"] == "neustart" assert zeilen["Neustart"]["text"] == "Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 )." def test_abgebrochener_lauf_ohne_zusammenfassung_ist_rot(): lauf = update_verlauf.laeufe_aus(PROTOKOLL)[-1] # 23.08. assert (lauf["stufe"], lauf["titel"]) == ("rot", "Fehler") assert lauf["zeilen"][0]["baustein"] == "Motor" assert lauf["zeilen"][0]["text"].startswith("Kritisch: Engine (llama.cpp)-Update auf 10588") def test_lauf_von_hand_mit_neustart_danach(): lauf = update_verlauf.laeufe_aus(PROTOKOLL)[4] # 04.09. nachmittags assert lauf["anlass"] == "Update von Hand" assert lauf["titel"] == "Eingespielt" assert lauf["zeilen"][-1]["baustein"] == "Neustart" assert lauf["zeilen"][2]["text"] == "1 Änderung eingespielt, Gehirn-Check grün." def test_morgenmeldung_ist_kein_eigener_lauf(): """Die Morgenmeldung um 07:00 wiederholt die Nachtmeldungen samt Wochen-Zusammenfassung — sie darf keinen zweiten Lauf „Update von Hand“ erzeugen.""" morgen = ( "2026-09-20 07:00:02 OK telegram: Guten Morgen, Commander. Heute Nacht gab es 2 Meldungen:\n" "- [Box-Update]: Router (llama-swap) aktualisiert: 255 → 256. Stack-Check grün, alles läuft.\n" "- [Box-Update]: Commander, die Wochenpflege der Box ist durch — kurz für dich:\n" "• Router (llama-swap): 255 → 256 eingespielt, Stack-Check grün.\n" ) ohne = update_verlauf.laeufe_aus(PROTOKOLL) mit = update_verlauf.laeufe_aus(PROTOKOLL.replace("2026-09-27 04:31:00", morgen + "2026-09-27 04:31:00")) assert [lauf["start"] for lauf in mit] == [lauf["start"] for lauf in ohne] def test_alles_aktuell_ist_grau(): lauf = update_verlauf.laeufe_aus(PROTOKOLL)[0] assert (lauf["stufe"], lauf["titel"]) == ("grau", "Alles aktuell") 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({ "engine": {"pinned": True, "version": "10819", "datum": "2026-09-13", "grund": "Update auf 10936 zerschoss den Stack-Check; Rollback grün"}, "hermes": {"pinned": True, "version": "63279301b", "datum": "2026-09-06", "grund": "x"}, "swap": {"pinned": False}, }), encoding="utf-8") monkeypatch.setattr(update_verlauf, "PINS_FILE", pins) fest = update_verlauf.festgehalten() assert [(p["baustein"], p["seit"]) for p in fest] == [("engine", "13.09."), ("hermes", "06.09.")] assert fest[0]["name"] == "Motor (llama.cpp)" assert update_verlauf.freigeben("engine") is True assert update_verlauf.freigeben("engine") is False rest = json.loads(pins.read_text(encoding="utf-8")) assert set(rest) == {"hermes", "swap"} def test_waechter_meldet_festgehaltene_bausteine(tmp_path, monkeypatch): from services import waechter pins = tmp_path / "mc2-pins.json" pins.write_text(json.dumps({"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13", "grund": "Update zerschoss den Stack-Check"}}), encoding="utf-8") monkeypatch.setattr(update_verlauf, "PINS_FILE", pins) befunde = waechter.pruefe_festgehalten() assert len(befunde) == 1 b = befunde[0] assert (b.id, b.stufe, b.sofort) == ("pin:engine", "gelb", True) assert "Seit 13.09. auf 10819 festgehalten" in b.text assert b.aktionen[0]["id"] == "freigeben" and b.aktionen[0]["baustein"] == "engine" def test_zweitweg_zaehlt_wie_telegram(): """notify.sh schreibt „OK telegram direkt“, wenn Hermes stand und die Bot-API direkt ansprang.""" text = ("2026-09-27 04:40:00 OK telegram direkt: Router (llama-swap) aktualisiert: 256 → 257. " "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"