init
This commit is contained in:
147
backend/scan_dienst.py
Normal file
147
backend/scan_dienst.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""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"]}
|
||||
Reference in New Issue
Block a user