diff --git a/.env.example b/.env.example index e4e810b..1b3d2f4 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,6 @@ CLAUDE_CODE_OAUTH_TOKEN= # oder ein API-Key ANTHROPIC_API_KEY= + +# Archiv für Quellen und Arbeitsdateien fertiger Versionen, z. B. die Storage Box; leer: alles bleibt in storage/ +ARCHIV_DIR= diff --git a/API.md b/API.md index 04b14a4..8a720db 100644 --- a/API.md +++ b/API.md @@ -108,3 +108,5 @@ storage// ``` Alte Ablagen (alles direkt unter `storage//`) werden beim Start nach `v1/` verschoben, fehlende Slugs nachgetragen. +Bei fertigen Versionen liegen `quellen/` (außer QUELLEN.md) und `arbeit/` im Archiv (`BUCHWERK_ARCHIV//vN/`), wenn eines +konfiguriert ist; Ereignisse dazu tragen den Agenten `archiv`. diff --git a/README.md b/README.md index 5c4ce91..0f4cb8d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ make logs / make stop `storage/` und `.claude-data/` (Sitzungen der CLI) werden als Volumes eingebunden; die Claude-Code-CLI liegt als Binary im SDK, Node wird im Container nicht gebraucht. +Archiv: ist `ARCHIV_DIR` in `.env` gesetzt (auf dem Server die Storage Box, Mount mit `uid=1000`, da der Container als +`app` schreibt), verschiebt `backend/archiv.py` nach jedem fertigen Lauf `quellen/` (außer QUELLEN.md) und `arbeit/` +der Version dorthin – das sind die Gigabytes an Klonen. Beim Start holt er nach, was fehlt. Ohne Variable bleibt alles in `storage/`. + Ablauf (backend/pipeline.py): Stufen seriell, innerhalb einer Stufe bis zu `PARALLEL` Agenten gleichzeitig. Jeder Agent arbeitet im Themenverzeichnis `storage//`; sein Abschlussbericht landet unter `berichte/` und dient als Marker: existiert der Bericht, gilt die Aufgabe als erledigt. Pause bricht laufende Agenten diff --git a/backend/archiv.py b/backend/archiv.py new file mode 100644 index 0000000..7309d1c --- /dev/null +++ b/backend/archiv.py @@ -0,0 +1,79 @@ +"""Quellen und Arbeitsdateien fertiger Versionen ins Archiv verschieben (config.ARCHIV, auf dem Server eine Storage Box). +Nichts liest danach noch daraus: neue Versionen recherchieren neu, fertige Läufe setzen nie fort. QUELLEN.md bleibt im Speicher, +die Buchansicht zeigt sie. Erst kopieren, dann löschen – ein Abbruch lässt den Speicher vollständig, der nächste Start holt nach.""" +import asyncio +import logging +import shutil +from pathlib import Path + +from . import config, state + +log = logging.getLogger("buchwerk") +ORDNER = ("quellen", "arbeit") +BLEIBT = {"QUELLEN.md"} +_aufgaben: set[asyncio.Task] = set() + + +def faellig(key: str) -> bool: + """Fertig und unter quellen/ oder arbeit/ liegt noch etwas.""" + v = state.verzeichnis(key) + return state.zustand(key)["status"] == "fertig" and any(_eintraege(v / u) for u in ORDNER) + + +def _eintraege(d: Path) -> list[Path]: + return [p for p in d.iterdir() if p.name not in BLEIBT] if d.is_dir() else [] + + +def auslagern(key: str) -> None: + """Verschiebt quellen/ (außer QUELLEN.md) und arbeit/ nach ARCHIV//. Blockiert – im Thread aufrufen.""" + v = state.verzeichnis(key) + for u in ORDNER: + for p in _eintraege(v / u): + _kopieren(p, config.ARCHIV / key / u / p.name) + if p.is_dir() and not p.is_symlink(): + shutil.rmtree(p) + else: + p.unlink() + + +def _kopieren(quelle: Path, ziel: Path) -> None: + """Ohne Rechte, Zeiten und Symlinks: CIFS kennt beides nicht. Links in Klonen entfallen.""" + if quelle.is_symlink(): + return + if quelle.is_dir(): + ziel.mkdir(parents=True, exist_ok=True) + for k in quelle.iterdir(): + _kopieren(k, ziel / k.name) + elif quelle.is_file(): + ziel.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(quelle, ziel) + + +async def _auslagern(key: str) -> None: + state.ereignis(key, "archiv", "start", f"Quellen und Arbeitsdateien nach {config.ARCHIV / key}") + try: + await asyncio.to_thread(auslagern, key) + state.ereignis(key, "archiv", "ende", "ausgelagert") + except OSError as e: + log.exception("Archiv %s", key) + state.ereignis(key, "archiv", "fehler", f"{type(e).__name__}: {e}") + state.speichern(key) + + +def anstossen(key: str) -> None: + """Im Hintergrund, der Lauf gilt schon als fertig.""" + if config.ARCHIV and faellig(key): + t = asyncio.create_task(_auslagern(key)) + _aufgaben.add(t) + t.add_done_callback(_aufgaben.discard) + + +async def nachholen() -> None: + """Beim Start: alle fertigen Versionen mit Restbestand, nacheinander (Bestand und abgebrochene Kopien).""" + if not config.ARCHIV: + return + for t in state.themen(): + for v in state.versionen(t["name"]): + key = f"{t['name']}/v{v}" + if faellig(key): + await _auslagern(key) diff --git a/backend/config.py b/backend/config.py index 73648b9..65dd00a 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,9 +1,12 @@ +import os from pathlib import Path WURZEL = Path(__file__).resolve().parent.parent STORAGE = WURZEL / "storage" PROMPTS = WURZEL / "prompts" FRONTEND_DIST = WURZEL / "frontend" / "dist" +# Quellen und Arbeitsdateien fertiger Versionen wandern hierhin (backend/archiv.py); leer: alles bleibt in storage/. +ARCHIV = Path(os.environ["BUCHWERK_ARCHIV"]) if os.environ.get("BUCHWERK_ARCHIV") else None PORT = 8020 MODELL = "claude-opus-5-5" diff --git a/backend/main.py b/backend/main.py index 78b5f9b..feb7222 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,3 +1,4 @@ +import asyncio import json import logging from contextlib import asynccontextmanager @@ -8,7 +9,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.middleware.gzip import GZipMiddleware from pydantic import BaseModel -from . import config, montage, pipeline, state +from . import archiv, config, montage, pipeline, state logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") @@ -17,7 +18,9 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name async def lifespan(app: FastAPI): config.STORAGE.mkdir(exist_ok=True) state.migrieren() + nachholen = asyncio.create_task(archiv.nachholen()) yield + nachholen.cancel() for name in list(pipeline._laeufe): await pipeline.pausieren(name) diff --git a/backend/pipeline.py b/backend/pipeline.py index 33b46c5..84558b7 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -3,7 +3,7 @@ Dateimarker (Bericht existiert = Agent fertig).""" import asyncio import logging -from . import agenten, config, gliederung, lektorat, montage, prompts, state +from . import agenten, archiv, config, gliederung, lektorat, montage, prompts, state log = logging.getLogger("buchwerk") _laeufe: dict[str, asyncio.Task] = {} @@ -67,6 +67,8 @@ async def _lauf(name: str) -> None: s["status"] = "fehler" finally: state.speichern(name) + if st["status"] == "fertig": + archiv.anstossen(name) def _offen(name: str) -> None: diff --git a/backend/state.py b/backend/state.py index f71c4bf..51a190e 100644 --- a/backend/state.py +++ b/backend/state.py @@ -85,9 +85,16 @@ def bezeichnung(name: str, spezialisierung: str) -> str: return f"{name} – {spezialisierung}" if spezialisierung else name +def _archiv_loeschen(key: str) -> None: + """Ausgelagerte Quellen einer Version oder eines Themas (backend/archiv.py) mit entfernen.""" + if config.ARCHIV: + shutil.rmtree(config.ARCHIV / key, ignore_errors=True) + + def version_loeschen(key: str) -> None: _cache.pop(key, None) shutil.rmtree(verzeichnis(key), ignore_errors=True) + _archiv_loeschen(key) name = thema_von(key) if not versionen(name): shutil.rmtree(config.STORAGE / name, ignore_errors=True) @@ -158,9 +165,11 @@ def loeschen(name: str) -> None: for k in [k for k in _cache if thema_von(k) == name]: _cache.pop(k, None) shutil.rmtree(config.STORAGE / name, ignore_errors=True) + _archiv_loeschen(name) def zuruecksetzen(key: str) -> None: + _archiv_loeschen(key) v = verzeichnis(key) h, stopp = hinweise_von(key), bool(zustand(key).get("stopp_gliederung")) _cache.pop(key, None) diff --git a/docker-compose.yml b/docker-compose.yml index da9da27..aee8a47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,13 @@ services: environment: - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - BUCHWERK_ARCHIV=${ARCHIV_DIR:+/archiv} networks: - web volumes: - ./storage:/app/storage - ./.claude-data:/home/app/.claude + - ${ARCHIV_DIR:-./archiv}:/archiv labels: - "traefik.enable=true" - "traefik.http.routers.buchwerk.rule=Host(`buchwerk.marha.de`)"