"""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 os.getenv("PLANER_FAKE_AGENTS"): 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"]}