148 lines
6.2 KiB
Python
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"]}
|