Lucy-Pipeline (S3): Auftragsbuch liest zwei Repos, Annahme baut am PC

Das Auftragsbuch-Gegenstueck fuer Lucy (Abloesungs-Paket S3, PC-Annahme-Weg):

- backend/services/auftragsbuch.py: Multi-Repo (mc2 + lucy via ~/lucy-Clone).
  Karten tragen repo-Feld; Status-Schluessel fuer Lucy = 'lucy:<branch>';
  fehlt ~/lucy, werden Lucy-Karten still weggelassen. Runner je Repo.
- deploy/lucy-annahme.sh: detached Annahme-Runner fuer Lucy-Karten.
  Vorpruefung am PC VOR dem Merge (Executor erreichbar, Arbeitskopie main+clean,
  laeuft Lucy?), dann Merge im isolierten Worktree von ~/lucy -> Push main ->
  PC zieht ff-only und baut detached (deploy/lucy-annahme.ps1 im Lucy-Repo),
  Box pollt .lucy-annahme.json und spiegelt Fortschritt auf die Karte.
  Rot = Merge automatisch revertiert, laufende Lucy bleibt die alte.
  Executor-Zugang (URL+Token) kommt aus ~/.hermes/config.yaml — kein zweiter
  Ablageort. Neustart nur, wenn Lucy vorher lief (User-Entscheid).
- Router/UI: repo-Parameter (rueckwaertskompatibel, Default mc2), rosa
  Lucy-Badge, eigener Annahme-Confirm-Text, Diff/Ablehnen je Repo.
- werkstatt-SOUL: Lucy-Auftraege ebenfalls propose-only, bauen macht der PC
  bei der Annahme.
- Doku: docs/AUFTRAGSBUCH.md (Lucy-Annahme-Kapitel), docs/wissen/OFFENE-FAEDEN
  (S2 erledigt, S3-Stand).

Geprueft: py_compile gruen, bash -n gruen, tsc+vite build gruen (dist dabei).
This commit is contained in:
Hitonabi
2026-07-12 14:48:03 +02:00
parent e951db36c5
commit 6d5b09a592
13 changed files with 409 additions and 127 deletions
+5 -4
View File
@@ -11,6 +11,7 @@ router = APIRouter(prefix="/api")
class BranchIn(BaseModel):
branch: str
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
class KandidatIn(BaseModel):
@@ -23,8 +24,8 @@ def list_proposals() -> dict:
@router.get("/auftragsbuch/diff")
def diff(branch: str) -> dict:
res = auftragsbuch.diff_of(branch)
def diff(branch: str, repo: str = "mc2") -> dict:
res = auftragsbuch.diff_of(branch, repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
return res
@@ -32,7 +33,7 @@ def diff(branch: str) -> dict:
@router.post("/auftragsbuch/annehmen")
def accept(body: BranchIn) -> dict:
res = auftragsbuch.accept(body.branch)
res = auftragsbuch.accept(body.branch, body.repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
return res
@@ -40,7 +41,7 @@ def accept(body: BranchIn) -> dict:
@router.post("/auftragsbuch/ablehnen")
def reject(body: BranchIn) -> dict:
res = auftragsbuch.reject(body.branch)
res = auftragsbuch.reject(body.branch, body.repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
return res
+90 -53
View File
@@ -4,13 +4,17 @@ Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt G
Quellen der Karten:
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App).
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
Annehmen (Branch) startet deploy/auftrag-annehmen.sh als EIGENE systemd-Unit (detached):
Merge im isolierten Worktree → Push origin/main → Deploy → Health → bei Rot Revert+Alarm.
Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
• mc2: deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot.
• lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor)
und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte).
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:<branch>" (kollisionsfrei).
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
"""
@@ -29,16 +33,24 @@ from config import MODELS_DIR
log = logging.getLogger(__name__)
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser()
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
# Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der
# Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC.
REPOS: dict[str, dict] = {
"mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""},
"lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"},
}
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
_fetch_cache: dict = {"ts": 0.0}
_fetch_cache: dict = {}
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
@@ -46,20 +58,29 @@ def _available() -> bool:
return os.name == "posix" and (REPO / ".git").exists()
def _git(args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(REPO), *args],
def _repo_ok(repo: str) -> bool:
r = REPOS.get(repo)
return bool(r) and os.name == "posix" and (r["path"] / ".git").exists()
def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args],
capture_output=True, text=True, timeout=timeout)
def _fetch_throttled() -> None:
def _status_key(repo: str, branch: str) -> str:
return f"{REPOS[repo]['key']}{branch}"
def _fetch_throttled(repo: str) -> None:
now = time.time()
if now - _fetch_cache["ts"] < _FETCH_EVERY:
if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
return
_fetch_cache["ts"] = now
_fetch_cache[repo] = now
try:
_git(["fetch", "-q", "--prune", "origin"], timeout=30)
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
except Exception:
log.warning("auftragsbuch: git fetch fehlgeschlagen", exc_info=True)
log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True)
def _statuses() -> dict:
@@ -69,13 +90,13 @@ def _statuses() -> dict:
return {}
def _set_status(branch: str, state: str, detail: str) -> None:
def _set_status(key: str, state: str, detail: str) -> None:
try:
try:
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
except Exception:
data = {}
data.setdefault("branches", {})[branch] = {"state": state, "detail": detail, "ts": time.time()}
data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
tmp = STATUS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATUS_PATH)
@@ -83,8 +104,8 @@ def _set_status(branch: str, state: str, detail: str) -> None:
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
def _remote_branches() -> list[str]:
r = _git(["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
def _remote_branches(repo: str) -> list[str]:
r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
out = []
for line in (r.stdout or "").splitlines():
name = line.strip().removeprefix("origin/")
@@ -97,26 +118,20 @@ def _valid_branch(branch: str) -> bool:
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
def list_proposals() -> dict:
"""Alle offenen Vorschläge: Branches (mit Commit-/Diff-Zusammenfassung + Status) und
Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite."""
if not _available():
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
_fetch_throttled()
statuses = _statuses()
def _repo_items(repo: str, statuses: dict) -> list[dict]:
items = []
for branch in _remote_branches():
for branch in _remote_branches(repo):
ref = f"origin/{branch}"
status = statuses.get(_status_key(repo, branch))
try:
ahead = int((_git(["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
if ahead == 0 and (statuses.get(branch, {}).get("state") not in ("laeuft", "rollback")):
ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
continue # bereits in main enthalten → keine offene Entscheidung mehr
behind = int((_git(["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
show = _git(["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
stat = (_git(["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
files_raw = (_git(["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
files = []
for line in files_raw[:60]:
parts = line.split("\t")
@@ -126,6 +141,7 @@ def list_proposals() -> dict:
frontend_src = any(p.startswith("frontend/src") for p in paths)
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
items.append({
"repo": repo,
"branch": branch,
"kind": branch.split("/", 1)[0],
"subject": subject.strip(),
@@ -137,13 +153,29 @@ def list_proposals() -> dict:
"shortstat": stat,
"files": files,
"files_truncated": len(files_raw) > 60,
# Frontend-Quelltext ohne gebautes Bundle: Box kann nicht bauen (kein Node) —
# nach dem Annehmen bliebe die Oberfläche alt, bis am PC gebaut wird.
"frontend_ohne_build": frontend_src and not frontend_dist,
"status": None if statuses.get(branch, {}).get("state") == "eingespielt" and ahead > 0 else statuses.get(branch),
# Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
# es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
"frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
})
except Exception:
log.warning("auftragsbuch: Branch %s nicht lesbar", branch, exc_info=True)
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
return items
def list_proposals() -> dict:
"""Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung +
Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite."""
if not _available():
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
statuses = _statuses()
items = []
for repo in REPOS:
if not _repo_ok(repo):
continue # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen
_fetch_throttled(repo)
items.extend(_repo_items(repo, statuses))
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
kandidaten = list_skill_kandidaten()
@@ -152,37 +184,41 @@ def list_proposals() -> dict:
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
def diff_of(branch: str) -> dict:
if not _available():
def diff_of(branch: str, repo: str = "mc2") -> dict:
if not _repo_ok(repo):
return {"ok": False, "error": "Nur auf der Box verfügbar."}
if not _valid_branch(branch) or branch not in _remote_branches():
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
r = _git(["diff", f"origin/main...origin/{branch}"], timeout=30)
r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30)
text = r.stdout or ""
truncated = len(text) > 200_000
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
def accept(branch: str) -> dict:
"""Annehmen: detached Runner starten (Merge→Push→Deploy→Health→ggf. Rollback)."""
if not _available():
def accept(branch: str, repo: str = "mc2") -> dict:
"""Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an)."""
if not _repo_ok(repo):
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches():
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
state = (_statuses().get(branch) or {}).get("state")
key = _status_key(repo, branch)
state = (_statuses().get(key) or {}).get("state")
if state in ("laeuft", "rollback"):
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
src = REPO / "deploy" / "auftrag-annehmen.sh"
src = REPO / "deploy" / REPOS[repo]["runner"]
if not src.is_file():
return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"}
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
try:
shutil.copyfile(src, runner)
except OSError as exc:
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
slug = re.sub(r"[^a-z0-9-]+", "-", branch.lower())[:40].strip("-")
slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-")
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
@@ -190,25 +226,26 @@ def accept(branch: str) -> dict:
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
_set_status(branch, "laeuft", "Annahme-Lauf gestartet")
_set_status(key, "laeuft", "Annahme-Lauf gestartet")
return {"ok": True, "unit": unit}
def reject(branch: str) -> dict:
def reject(branch: str, repo: str = "mc2") -> dict:
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
aber die Entscheidung ist gefallen). Meldung in den Briefkasten — ehrlich und sichtbar."""
if not _available():
if not _repo_ok(repo):
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches():
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
r = _git(["push", "origin", "--delete", branch], timeout=30)
r = _git(repo, ["push", "origin", "--delete", branch], timeout=30)
if r.returncode != 0:
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
_git(["fetch", "-q", "--prune", "origin"], timeout=30)
_set_status(branch, "abgelehnt", "Vom Commander abgelehnt — Branch gelöscht")
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
_set_status(_status_key(repo, branch), "abgelehnt", "Vom Commander abgelehnt — Branch gelöscht")
try:
from services import announce
announce.add(f"Vorschlag '{branch}' wurde abgelehnt und der Branch gelöscht.",
wo = "im Lucy-Repo " if repo == "lucy" else ""
announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass