Files
planer/backend/scan_dienst.py
2026-07-23 11:54:31 +02:00

148 lines
6.2 KiB
Python

"""Scan-Dienst: Vorflug-Checks, Re-Scan-Planung und Hintergrund-Läufe.
Eine Wahrheit für CLI und Server: cli.py und routes.py delegieren beide hierher.
Doppelstart-Guard nach Creator-Muster: prozess-lokales Dict + idempotente Antwort +
Re-Entry-Check in der Coroutine (gilt nur im eigenen Prozess — bei laufendem Server
Scans über die UI starten, nicht parallel per CLI).
"""
import asyncio
import logging
import os
import time
from pathlib import Path
import agents
import artefakte
import chunker
import config
import repo
import scan_board
log = logging.getLogger("planer.dienst")
class VorflugFehler(Exception):
"""Lauf darf nicht starten (kein Repo, dirty tree, Rolle/Provider unbrauchbar)."""
def vorflug(wurzel: Path) -> str:
"""Checks vor jedem Lauf → HEAD-Hash. Wirft VorflugFehler (Erkenntnis 13:
lieber vorher stoppen als leer verbrennen)."""
if not wurzel.is_dir():
raise VorflugFehler(f"{wurzel} ist kein Verzeichnis")
if not repo.ist_repo(wurzel):
raise VorflugFehler(f"{wurzel} ist kein git-Repo — Anker brauchen Commit-Hashes (git init && commit)")
if not repo.working_tree_sauber(wurzel):
raise VorflugFehler(f"{wurzel} hat uncommittete Änderungen — erst committen, dann scannen")
if not config.fake_agents_aktiv():
for rolle in config.ROLLEN:
try:
provider, _ = config.resolve_role(rolle)
except RuntimeError as e:
raise VorflugFehler(str(e)) from e
if not agents.provider_available(provider):
raise VorflugFehler(f"Provider '{provider}' (Rolle {rolle}) nicht nutzbar — CLI/Key prüfen")
return repo.head_hash(wurzel)
def einheiten_bestand(wurzel: Path) -> dict[str, tuple[str, str]]:
"""{quelldatei: (stand, text)} aus den vorhandenen .planer/einheiten/-Dateien."""
out: dict[str, tuple[str, str]] = {}
verzeichnis = wurzel / ".planer" / "einheiten"
for p in verzeichnis.glob("*.md") if verzeichnis.is_dir() else []:
text = p.read_text(encoding="utf-8")
kopf = text.splitlines()[0] if text else ""
datei = kopf.removeprefix("# Einheiten:").strip()
stand = artefakte.lese_stand(text)
if datei and stand:
out[datei] = (stand, text)
return out
def rescan_plan(wurzel: Path) -> tuple[set[str], dict[str, str], list[str]]:
"""→ (zu_scannen, unveraendert {datei: text}, geloeschte_quelldateien).
Wirft VorflugFehler, wenn noch kein Scan existiert."""
bestand = einheiten_bestand(wurzel)
if not bestand:
raise VorflugFehler("keine .planer/einheiten/ gefunden — erst `scan` ausführen")
scanbar = {str(p.relative_to(wurzel)) for p in chunker.dateien(wurzel)}
diff_cache: dict[str, set[str]] = {}
def geaendert_seit(stand: str) -> set[str]:
if stand not in diff_cache:
diff_cache[stand] = repo.geaenderte_dateien(wurzel, stand)[0]
return diff_cache[stand]
zu_scannen: set[str] = scanbar - set(bestand)
unveraendert: dict[str, str] = {}
for datei, (stand_alt, text) in bestand.items():
if datei not in scanbar:
continue
if stand_alt == "UNBEKANNT" or datei in geaendert_seit(stand_alt):
zu_scannen.add(datei)
else:
unveraendert[datei] = text
geloescht = [datei for datei in bestand if datei not in scanbar]
return zu_scannen, unveraendert, geloescht
async def rescan_lauf(wurzel: Path, projekt: str, stand: str, set_p=None) -> dict:
"""Kompletter Re-Scan: Plan, Aufräumen gelöschter Artefakte, Lauf. Keine Änderungen →
{status: 'unveraendert'}."""
zu_scannen, unveraendert, geloescht = rescan_plan(wurzel)
for datei in geloescht:
(wurzel / ".planer" / artefakte.datei_pfad(datei)).unlink(missing_ok=True)
if not zu_scannen and not geloescht:
return {"status": "unveraendert", "geschrieben": [], "dead": []}
if not zu_scannen and unveraendert:
# Nur Löschungen: Sichten müssen neu — kleinste verbliebene Datei mit-rescannen
zu_scannen = {min(unveraendert, key=lambda d: len(unveraendert[d]))}
unveraendert.pop(next(iter(zu_scannen)))
log.info("rescan %s: %d neu, %d unverändert, %d gelöscht",
projekt, len(zu_scannen), len(unveraendert), len(geloescht))
return await scan_board.run_scan(wurzel, projekt, stand, set_p=set_p,
nur_dateien=zu_scannen, bestand=unveraendert)
# --- Hintergrund-Läufe (Server) --------------------------------------------------------
_laeufe: dict[str, dict] = {} # projekt → {task, art, gestartet, ergebnis}
def starte(name: str, wurzel: Path, art: str, fortsetzen: bool = False) -> str:
"""Startet scan|rescan als Hintergrund-Task. → 'gestartet' | 'laeuft'.
Wirft VorflugFehler synchron (Route antwortet 409 mit Meldung)."""
eintrag = _laeufe.get(name)
if eintrag and not eintrag["task"].done():
return "laeuft"
stand = vorflug(wurzel)
if art == "rescan":
rescan_plan(wurzel) # wirft synchron, wenn es keinen Erst-Scan gibt
async def lauf():
if (e := _laeufe.get(name)) and e.get("laeuft_intern"): # Re-Entry-Schutz
return
_laeufe[name]["laeuft_intern"] = True
try:
if art == "rescan":
ergebnis = await rescan_lauf(wurzel, name, stand)
else:
ergebnis = await scan_board.run_scan(wurzel, name, stand, fortsetzen=fortsetzen)
_laeufe[name]["ergebnis"] = ergebnis
except Exception as e: # Status muss den Fehler zeigen, nie stiller Tod
log.exception("lauf %s (%s) abgebrochen", name, art)
_laeufe[name]["ergebnis"] = {"status": "abbruch", "infra_error": str(e),
"geschrieben": [], "dead": []}
_laeufe[name] = {"task": asyncio.create_task(lauf()), "art": art,
"gestartet": time.time(), "ergebnis": None}
return "gestartet"
def status(name: str) -> dict | None:
eintrag = _laeufe.get(name)
if eintrag is None:
return None
return {"laeuft": not eintrag["task"].done(), "art": eintrag["art"],
"gestartet": eintrag["gestartet"], "ergebnis": eintrag["ergebnis"]}