Files
mission-control-v2/backend/tests/test_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

230 lines
13 KiB
Python

"""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"