Archiv: Quellen und Arbeitsdateien fertiger Versionen auf ARCHIV_DIR auslagern (z. B. Storage Box)
Nach jedem fertigen Lauf und beim Start nachholend; QUELLEN.md bleibt. Löschen und Zurücksetzen räumen das Archiv mit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
478ce6b412
commit
be19abc065
9 files changed
+109
-2
No files matched your search
@@ -3,3 +3,6 @@
|
|||||||
CLAUDE_CODE_OAUTH_TOKEN=
|
CLAUDE_CODE_OAUTH_TOKEN=
|
||||||
# oder ein API-Key
|
# oder ein API-Key
|
||||||
ANTHROPIC_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=
|
||||||
@@ -108,3 +108,5 @@ storage/<Thema>/
|
|||||||
```
|
```
|
||||||
|
|
||||||
Alte Ablagen (alles direkt unter `storage/<Thema>/`) werden beim Start nach `v1/` verschoben, fehlende Slugs nachgetragen.
|
Alte Ablagen (alles direkt unter `storage/<Thema>/`) werden beim Start nach `v1/` verschoben, fehlende Slugs nachgetragen.
|
||||||
|
Bei fertigen Versionen liegen `quellen/` (außer QUELLEN.md) und `arbeit/` im Archiv (`BUCHWERK_ARCHIV/<Thema>/vN/`), wenn eines
|
||||||
|
konfiguriert ist; Ereignisse dazu tragen den Agenten `archiv`.
|
||||||
@@ -24,6 +24,10 @@ make logs / make stop
|
|||||||
`storage/` und `.claude-data/` (Sitzungen der CLI) werden als Volumes eingebunden; die Claude-Code-CLI
|
`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.
|
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.
|
Ablauf (backend/pipeline.py): Stufen seriell, innerhalb einer Stufe bis zu `PARALLEL` Agenten gleichzeitig.
|
||||||
Jeder Agent arbeitet im Themenverzeichnis `storage/<Thema>/`; sein Abschlussbericht landet unter `berichte/`
|
Jeder Agent arbeitet im Themenverzeichnis `storage/<Thema>/`; sein Abschlussbericht landet unter `berichte/`
|
||||||
und dient als Marker: existiert der Bericht, gilt die Aufgabe als erledigt. Pause bricht laufende Agenten
|
und dient als Marker: existiert der Bericht, gilt die Aufgabe als erledigt. Pause bricht laufende Agenten
|
||||||
|
|||||||
@@ -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/<key>/. 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)
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
WURZEL = Path(__file__).resolve().parent.parent
|
WURZEL = Path(__file__).resolve().parent.parent
|
||||||
STORAGE = WURZEL / "storage"
|
STORAGE = WURZEL / "storage"
|
||||||
PROMPTS = WURZEL / "prompts"
|
PROMPTS = WURZEL / "prompts"
|
||||||
FRONTEND_DIST = WURZEL / "frontend" / "dist"
|
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
|
PORT = 8020
|
||||||
MODELL = "claude-opus-5-5"
|
MODELL = "claude-opus-5-5"
|
||||||
|
|||||||
+4
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -8,7 +9,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from pydantic import BaseModel
|
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")
|
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):
|
async def lifespan(app: FastAPI):
|
||||||
config.STORAGE.mkdir(exist_ok=True)
|
config.STORAGE.mkdir(exist_ok=True)
|
||||||
state.migrieren()
|
state.migrieren()
|
||||||
|
nachholen = asyncio.create_task(archiv.nachholen())
|
||||||
yield
|
yield
|
||||||
|
nachholen.cancel()
|
||||||
for name in list(pipeline._laeufe):
|
for name in list(pipeline._laeufe):
|
||||||
await pipeline.pausieren(name)
|
await pipeline.pausieren(name)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -3,7 +3,7 @@ Dateimarker (Bericht existiert = Agent fertig)."""
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
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")
|
log = logging.getLogger("buchwerk")
|
||||||
_laeufe: dict[str, asyncio.Task] = {}
|
_laeufe: dict[str, asyncio.Task] = {}
|
||||||
@@ -67,6 +67,8 @@ async def _lauf(name: str) -> None:
|
|||||||
s["status"] = "fehler"
|
s["status"] = "fehler"
|
||||||
finally:
|
finally:
|
||||||
state.speichern(name)
|
state.speichern(name)
|
||||||
|
if st["status"] == "fertig":
|
||||||
|
archiv.anstossen(name)
|
||||||
|
|
||||||
|
|
||||||
def _offen(name: str) -> None:
|
def _offen(name: str) -> None:
|
||||||
|
|||||||
@@ -85,9 +85,16 @@ def bezeichnung(name: str, spezialisierung: str) -> str:
|
|||||||
return f"{name} – {spezialisierung}" if spezialisierung else name
|
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:
|
def version_loeschen(key: str) -> None:
|
||||||
_cache.pop(key, None)
|
_cache.pop(key, None)
|
||||||
shutil.rmtree(verzeichnis(key), ignore_errors=True)
|
shutil.rmtree(verzeichnis(key), ignore_errors=True)
|
||||||
|
_archiv_loeschen(key)
|
||||||
name = thema_von(key)
|
name = thema_von(key)
|
||||||
if not versionen(name):
|
if not versionen(name):
|
||||||
shutil.rmtree(config.STORAGE / name, ignore_errors=True)
|
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]:
|
for k in [k for k in _cache if thema_von(k) == name]:
|
||||||
_cache.pop(k, None)
|
_cache.pop(k, None)
|
||||||
shutil.rmtree(config.STORAGE / name, ignore_errors=True)
|
shutil.rmtree(config.STORAGE / name, ignore_errors=True)
|
||||||
|
_archiv_loeschen(name)
|
||||||
|
|
||||||
|
|
||||||
def zuruecksetzen(key: str) -> None:
|
def zuruecksetzen(key: str) -> None:
|
||||||
|
_archiv_loeschen(key)
|
||||||
v = verzeichnis(key)
|
v = verzeichnis(key)
|
||||||
h, stopp = hinweise_von(key), bool(zustand(key).get("stopp_gliederung"))
|
h, stopp = hinweise_von(key), bool(zustand(key).get("stopp_gliederung"))
|
||||||
_cache.pop(key, None)
|
_cache.pop(key, None)
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
|
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- BUCHWERK_ARCHIV=${ARCHIV_DIR:+/archiv}
|
||||||
networks:
|
networks:
|
||||||
- web
|
- web
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/app/storage
|
- ./storage:/app/storage
|
||||||
- ./.claude-data:/home/app/.claude
|
- ./.claude-data:/home/app/.claude
|
||||||
|
- ${ARCHIV_DIR:-./archiv}:/archiv
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.buchwerk.rule=Host(`buchwerk.marha.de`)"
|
- "traefik.http.routers.buchwerk.rule=Host(`buchwerk.marha.de`)"
|
||||||
|
|||||||
Reference in new issue
Block a user