This commit is contained in:
team3
2026-07-23 11:54:31 +02:00
parent f07ef9653d
commit 09f9ea74a6
18 changed files with 364 additions and 85 deletions

View File

@@ -29,7 +29,7 @@ import httpx
from config import (MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_AGENTS_PER_TOPIC,
MAX_CONCURRENT_API_AGENTS, OPENCODE_CONFIG, PROVIDERS,
RAM_MIN_FREE_PCT, resolve_role)
RAM_MIN_FREE_PCT, fake_agents_aktiv, resolve_role)
log = logging.getLogger("planer.agents")
@@ -270,7 +270,7 @@ async def run_agent(
"""Ein LLM-Call der Rolle `role`. → (rc, stdout, stderr). Wirft AgentInfraError,
wenn die Infrastruktur nicht nutzbar ist (Rolle unkonfiguriert, Provider down,
leere Antwort ohne Session) — der Lauf soll dann pausieren, nicht raten."""
if os.getenv("PLANER_FAKE_AGENTS"): # Sekunden-Smoke: deterministisch statt LLM
if fake_agents_aktiv(): # Sekunden-Smoke: deterministisch statt LLM
import fake_agents
start = time.monotonic()
res = await fake_agents.respond(agent_key, prompt, capabilities)
@@ -514,7 +514,10 @@ async def _run_text_api(agent_key: str, prompt: str, timeout: int, model: str,
"messages": [{"role": "user", "content": prompt}],
**_api_model_opts(model),
}
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": _API_VERSION}
api_key = os.environ.get("MINIMAX_API_KEY")
if not api_key:
raise AgentInfraError("MINIMAX_API_KEY fehlt — in .env setzen")
headers = {"x-api-key": api_key, "anthropic-version": _API_VERSION}
track_key = agent_key
n = 2
while track_key in _active_api:

View File

@@ -61,6 +61,12 @@ def resolve_role(role: str) -> tuple[str, str]:
return provider, modell
def fake_agents_aktiv() -> bool:
"""PLANER_FAKE_AGENTS: leer oder "0" = aus. Ein truthy "0" würde sonst still den
Fake-Modus (und damit den Rollen-Vorflug-Check) aktivieren."""
return os.getenv("PLANER_FAKE_AGENTS", "").strip() not in ("", "0")
# --- Engine-Konstanten (Kanban + Agenten) -----------------------------------------
KANBAN_BATCH = int(os.getenv("KANBAN_BATCH", "4")) # Karten je Paket
MAX_CARD_RETRIES = int(os.getenv("MAX_CARD_RETRIES", "3")) # dann dead-letter

View File

@@ -1,5 +1,5 @@
"""Projektliste: konfigurierte Pfade aus projekte.txt (eine Zeile = absoluter Pfad,
#-Kommentare). Von Hand editierbar — UI-Verwaltung kommt später (YAGNI)."""
#-Kommentare). Von Hand editierbar; hinzufuegen/entfernen bedienen die UI."""
from pathlib import Path
import config
@@ -34,3 +34,41 @@ def finde(name: str) -> Path | None:
if p["name"] == name and "fehler" not in p:
return Path(p["pfad"])
return None
def hinzufuegen(pfad: str) -> None:
"""Hängt einen Pfad an projekte.txt an. Wirft ValueError bei ungültigem
Verzeichnis, Duplikat oder Basename-Kollision."""
p = Path(pfad).expanduser()
if not p.is_absolute():
raise ValueError("Pfad muss absolut sein")
if not p.is_dir():
raise ValueError(f"{p} ist kein Verzeichnis")
for e in lade():
if e["pfad"] == str(p):
raise ValueError(f"{p} ist schon eingetragen")
if e["name"] == p.name:
raise ValueError(f"Namenskollision mit {e['pfad']}")
text = ""
if config.PROJEKTE_DATEI.is_file():
text = config.PROJEKTE_DATEI.read_text(encoding="utf-8")
if text and not text.endswith("\n"):
text += "\n"
config.PROJEKTE_DATEI.write_text(text + str(p) + "\n", encoding="utf-8")
def entfernen(name: str) -> None:
"""Streicht den Eintrag aus projekte.txt — nur die Zeile; .planer/ im Repo
bleibt unberührt. Wirft ValueError, wenn der Name nicht eingetragen ist."""
if not config.PROJEKTE_DATEI.is_file():
raise ValueError(f"'{name}' nicht in projekte.txt")
rest, getroffen = [], False
for zeile in config.PROJEKTE_DATEI.read_text(encoding="utf-8").splitlines():
s = zeile.strip()
if s and not s.startswith("#") and Path(s).expanduser().name == name:
getroffen = True
continue
rest.append(zeile)
if not getroffen:
raise ValueError(f"'{name}' nicht in projekte.txt")
config.PROJEKTE_DATEI.write_text("\n".join(rest) + ("\n" if rest else ""), encoding="utf-8")

View File

@@ -24,6 +24,47 @@ async def health():
return {"ok": True}
@router.get("/fs")
async def fs(pfad: str = ""):
"""Ordner-Browser für die Projekt-Auswahl: nur Verzeichnisse, keine versteckten."""
basis = Path(pfad).expanduser() if pfad else Path.home()
if not basis.is_dir():
raise HTTPException(404, f"{basis} ist kein Verzeichnis")
basis = basis.resolve()
try:
kinder = sorted(basis.iterdir(), key=lambda p: p.name.lower())
except PermissionError:
raise HTTPException(403, f"kein Zugriff auf {basis}")
ordner = [{"name": k.name, "pfad": str(k), "hat_planer": (k / ".planer").is_dir()}
for k in kinder if k.is_dir() and not k.name.startswith(".")]
eltern = str(basis.parent) if basis.parent != basis else None
return {"pfad": str(basis), "eltern": eltern, "ordner": ordner}
@router.post("/projekte")
async def projekt_hinzufuegen(body: dict):
pfad = (body.get("pfad") or "").strip()
if not pfad:
raise HTTPException(400, "pfad fehlt")
try:
projekte.hinzufuegen(pfad)
except ValueError as e:
raise HTTPException(409, str(e))
return {"ok": True}
@router.delete("/projekte/{name}")
async def projekt_entfernen(name: str):
lauf = scan_dienst.status(name)
if lauf and lauf["laeuft"]:
raise HTTPException(409, "Scan läuft — erst abwarten oder abbrechen")
try:
projekte.entfernen(name)
except ValueError as e:
raise HTTPException(404, str(e))
return {"ok": True}
@router.get("/projekte")
async def liste():
out = []

View File

@@ -34,7 +34,7 @@ def vorflug(wurzel: Path) -> str:
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"):
if not config.fake_agents_aktiv():
for rolle in config.ROLLEN:
try:
provider, _ = config.resolve_role(rolle)

View File

@@ -85,3 +85,34 @@ async def test_scan_409_bei_dirty_tree(client):
r = await c.post("/api/projekte/mini/scan", json={"art": "scan"})
assert r.status_code == 409
assert "uncommittete" in r.json()["detail"]
async def test_fs_browse(client, tmp_path):
c, _, _ = client
(tmp_path / "unter" / ".planer").mkdir(parents=True)
(tmp_path / ".versteckt").mkdir()
r = await c.get("/api/fs", params={"pfad": str(tmp_path)})
daten = r.json()
assert daten["pfad"] == str(tmp_path.resolve())
assert daten["eltern"] == str(tmp_path.resolve().parent)
ordner = {o["name"]: o for o in daten["ordner"]}
assert ordner["unter"]["hat_planer"] is True
assert ".versteckt" not in ordner
assert (await c.get("/api/fs", params={"pfad": str(tmp_path / "fehlt")})).status_code == 404
async def test_projekt_hinzufuegen_und_entfernen(client, tmp_path):
c, _, _ = client
neu = tmp_path / "neues-repo"
(neu / ".planer").mkdir(parents=True)
assert (await c.post("/api/projekte", json={"pfad": str(neu)})).status_code == 200
namen = [p["name"] for p in (await c.get("/api/projekte")).json()]
assert "neues-repo" in namen and "mini" in namen
assert (await c.post("/api/projekte", json={"pfad": str(neu)})).status_code == 409
assert (await c.delete("/api/projekte/neues-repo")).status_code == 200
namen = [p["name"] for p in (await c.get("/api/projekte")).json()]
assert "neues-repo" not in namen and "mini" in namen
assert (neu / ".planer").is_dir() # Artefakte bleiben erhalten
assert (await c.delete("/api/projekte/neues-repo")).status_code == 404