Auftragsbuch: 'feature/lucy-pipeline' angenommen (Ein-Klick-Gate)

This commit is contained in:
Auftragsbuch
2026-07-12 14:58:04 +02:00
13 changed files with 409 additions and 127 deletions
+5 -4
View File
@@ -11,6 +11,7 @@ router = APIRouter(prefix="/api")
class BranchIn(BaseModel): class BranchIn(BaseModel):
branch: str branch: str
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
class KandidatIn(BaseModel): class KandidatIn(BaseModel):
@@ -23,8 +24,8 @@ def list_proposals() -> dict:
@router.get("/auftragsbuch/diff") @router.get("/auftragsbuch/diff")
def diff(branch: str) -> dict: def diff(branch: str, repo: str = "mc2") -> dict:
res = auftragsbuch.diff_of(branch) res = auftragsbuch.diff_of(branch, repo)
if not res.get("ok"): if not res.get("ok"):
raise HTTPException(400, res.get("error", "Diff nicht verfügbar.")) raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
return res return res
@@ -32,7 +33,7 @@ def diff(branch: str) -> dict:
@router.post("/auftragsbuch/annehmen") @router.post("/auftragsbuch/annehmen")
def accept(body: BranchIn) -> dict: def accept(body: BranchIn) -> dict:
res = auftragsbuch.accept(body.branch) res = auftragsbuch.accept(body.branch, body.repo)
if not res.get("ok"): if not res.get("ok"):
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen.")) raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
return res return res
@@ -40,7 +41,7 @@ def accept(body: BranchIn) -> dict:
@router.post("/auftragsbuch/ablehnen") @router.post("/auftragsbuch/ablehnen")
def reject(body: BranchIn) -> dict: def reject(body: BranchIn) -> dict:
res = auftragsbuch.reject(body.branch) res = auftragsbuch.reject(body.branch, body.repo)
if not res.get("ok"): if not res.get("ok"):
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen.")) raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
return res 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: Quellen der Karten:
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der • Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen. 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 • Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach". nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
Annehmen (Branch) startet deploy/auftrag-annehmen.sh als EIGENE systemd-Unit (detached): Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
Merge im isolierten Worktree → Push origin/main → Deploy → Health → bei Rot Revert+Alarm. • 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. 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. 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. 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__) log = logging.getLogger(__name__)
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser() 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() 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"))) 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). # Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/") PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion). # 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}$") _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$") _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 _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() return os.name == "posix" and (REPO / ".git").exists()
def _git(args: list[str], timeout: int = 20) -> subprocess.CompletedProcess: def _repo_ok(repo: str) -> bool:
return subprocess.run(["git", "-C", str(REPO), *args], 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) 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() now = time.time()
if now - _fetch_cache["ts"] < _FETCH_EVERY: if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
return return
_fetch_cache["ts"] = now _fetch_cache[repo] = now
try: try:
_git(["fetch", "-q", "--prune", "origin"], timeout=30) _git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
except Exception: 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: def _statuses() -> dict:
@@ -69,13 +90,13 @@ def _statuses() -> dict:
return {} return {}
def _set_status(branch: str, state: str, detail: str) -> None: def _set_status(key: str, state: str, detail: str) -> None:
try: try:
try: try:
data = json.loads(STATUS_PATH.read_text(encoding="utf-8")) data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
except Exception: except Exception:
data = {} 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 = STATUS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATUS_PATH) 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) log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
def _remote_branches() -> list[str]: def _remote_branches(repo: str) -> list[str]:
r = _git(["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"]) r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
out = [] out = []
for line in (r.stdout or "").splitlines(): for line in (r.stdout or "").splitlines():
name = line.strip().removeprefix("origin/") 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) return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
def list_proposals() -> dict: def _repo_items(repo: str, statuses: dict) -> list[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()
items = [] items = []
for branch in _remote_branches(): for branch in _remote_branches(repo):
ref = f"origin/{branch}" ref = f"origin/{branch}"
status = statuses.get(_status_key(repo, branch))
try: try:
ahead = int((_git(["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0) ahead = int((_git(repo, ["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")): if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
continue # bereits in main enthalten → keine offene Entscheidung mehr 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) behind = int((_git(repo, ["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]) show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4] subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
stat = (_git(["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip() stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
files_raw = (_git(["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines() files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
files = [] files = []
for line in files_raw[:60]: for line in files_raw[:60]:
parts = line.split("\t") parts = line.split("\t")
@@ -126,6 +141,7 @@ def list_proposals() -> dict:
frontend_src = any(p.startswith("frontend/src") for p in paths) frontend_src = any(p.startswith("frontend/src") for p in paths)
frontend_dist = any(p.startswith("frontend/dist") for p in paths) frontend_dist = any(p.startswith("frontend/dist") for p in paths)
items.append({ items.append({
"repo": repo,
"branch": branch, "branch": branch,
"kind": branch.split("/", 1)[0], "kind": branch.split("/", 1)[0],
"subject": subject.strip(), "subject": subject.strip(),
@@ -137,13 +153,29 @@ def list_proposals() -> dict:
"shortstat": stat, "shortstat": stat,
"files": files, "files": files,
"files_truncated": len(files_raw) > 60, "files_truncated": len(files_raw) > 60,
# Frontend-Quelltext ohne gebautes Bundle: Box kann nicht bauen (kein Node) — # Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
# nach dem Annehmen bliebe die Oberfläche alt, bis am PC gebaut wird. # es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
"frontend_ohne_build": frontend_src and not frontend_dist, "frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
"status": None if statuses.get(branch, {}).get("state") == "eingespielt" and ahead > 0 else statuses.get(branch), "status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
}) })
except Exception: 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) items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
kandidaten = list_skill_kandidaten() kandidaten = list_skill_kandidaten()
@@ -152,37 +184,41 @@ def list_proposals() -> dict:
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count} return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
def diff_of(branch: str) -> dict: def diff_of(branch: str, repo: str = "mc2") -> dict:
if not _available(): if not _repo_ok(repo):
return {"ok": False, "error": "Nur auf der Box verfügbar."} 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}"} 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 "" text = r.stdout or ""
truncated = len(text) > 200_000 truncated = len(text) > 200_000
return {"ok": True, "diff": text[:200_000], "truncated": truncated} return {"ok": True, "diff": text[:200_000], "truncated": truncated}
def accept(branch: str) -> dict: def accept(branch: str, repo: str = "mc2") -> dict:
"""Annehmen: detached Runner starten (Merge→Push→Deploy→Health→ggf. Rollback).""" """Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
if not _available(): 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."} 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}"} 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"): if state in ("laeuft", "rollback"):
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."} 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 # 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). # 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") runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
try: try:
shutil.copyfile(src, runner) shutil.copyfile(src, runner)
except OSError as exc: except OSError as exc:
return {"ok": False, "error": f"Runner nicht kopierbar: {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())}" unit = f"mc2-auftrag-{slug}-{int(time.time())}"
r = subprocess.run( r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}", ["systemd-run", "--user", "--collect", f"--unit={unit}",
@@ -190,25 +226,26 @@ def accept(branch: str) -> dict:
capture_output=True, text=True, timeout=20) capture_output=True, text=True, timeout=20)
if r.returncode != 0: if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"} 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} 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, """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.""" 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."} 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}"} 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: if r.returncode != 0:
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"} return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
_git(["fetch", "-q", "--prune", "origin"], timeout=30) _git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
_set_status(branch, "abgelehnt", "Vom Commander abgelehnt — Branch gelöscht") _set_status(_status_key(repo, branch), "abgelehnt", "Vom Commander abgelehnt — Branch gelöscht")
try: try:
from services import announce 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") "[Auftragsbuch]", "auftragsbuch", "silent")
except Exception: except Exception:
pass pass
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env bash
# Auftragsbuch: einen LUCY-Vorschlags-Branch ANNEHMEN — das PC-Gegenstück zu auftrag-annehmen.sh.
# Lucy (Electron) lebt auf dem Windows-PC (F:\Coding Stuff\lucy); dist liegt NICHT im Repo.
# Annehmen heißt darum: Merge auf main (hier, Box) → der PC zieht main, baut dist neu und
# startet Lucy neu, WENN sie lief (User-Entscheid: Lucy startet nur, wenn der Commander sie will).
# Schlägt am PC irgendetwas fehl, wird der Merge auf main automatisch revertiert — die laufende
# Lucy bleibt die alte (der PC-Runner stellt dist aus seinem Backup wieder her).
#
# Der PC wird über den PC-Executor (client/hermes-pc/executor.py, :7777, Bearer-Token aus
# ~/.hermes/config.yaml) gesteuert. /shell hat 60 s Timeout → der Build läuft DETACHED
# (deploy/lucy-annahme.ps1 im Lucy-Repo schreibt eine Status-Datei, wir pollen sie).
#
# Läuft wie der MC2-Runner als EIGENE systemd-Unit und als /tmp-Kopie (Selbst-Reset-Falle).
# Status-Schlüssel in der Auftragsbuch-JSON: "lucy:<branch>".
#
# Nutzung: lucy-annahme.sh <branch> (z. B. wartung/turncheck-default-aus)
set -uo pipefail
BRANCH="${1:?Nutzung: lucy-annahme.sh <branch>}"
SRC="${MC2_SRC:-$HOME/mission-control-v2}"
LUCY="${MC2_LUCY_DIR:-$HOME/lucy}"
STATUS="${MC2_AUFTRAG_STATUS:-/srv/models/mc2-auftragsbuch.json}"
HERMES_CONF="${HERMES_CONF:-$HOME/.hermes/config.yaml}"
SLUG="lucy-$(echo "$BRANCH" | tr '/' '-')"
WT="/tmp/annahme-$SLUG"
LOG="/tmp/annahme-$SLUG.log"
# Windows-Seite (Pfade im PC-Dateisystem; einfach gehalten — EIN PC, EIN Checkout).
PC_REPO='F:\Coding Stuff\lucy'
PC_STATUSFILE='F:\Coding Stuff\lucy\.lucy-annahme.json'
PC_RUNNER='F:\Coding Stuff\lucy\deploy\lucy-annahme.ps1'
BUILD_TIMEOUT_S=1500 # 25 min Deckel für npm ci + tsc + electron-builder + Stimm-Warmup
notify(){ bash "$SRC/deploy/notify.sh" -s "[Auftragsbuch]" "$1" || true; }
# Status-Fortschritt für die UI (atomar via tmp+replace; die API liest die Datei nur).
status(){ # $1=state $2=detail
python3 - "$STATUS" "lucy:$BRANCH" "$1" "$2" <<'PY'
import json, os, sys, time
p, key, state, detail = sys.argv[1:5]
try:
d = json.load(open(p, encoding="utf-8"))
except Exception:
d = {}
d.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
tmp = p + ".tmp"
json.dump(d, open(tmp, "w", encoding="utf-8"), ensure_ascii=False)
os.replace(tmp, p)
PY
}
# PC-Executor: PowerShell-Kommando ausführen. stdout des PCs → stdout; rc 0 nur wenn
# Transport UND Kommando grün. Token/URL kommen aus der Hermes-Config (kein zweiter Ablageort).
PC_URL="$(sed -n 's/^[[:space:]]*PC_EXECUTOR_URL:[[:space:]]*//p' "$HERMES_CONF" | head -1 | tr -d "\"'" | tr -d '[:space:]')"
PC_TOKEN="$(sed -n 's/^[[:space:]]*PC_EXECUTOR_TOKEN:[[:space:]]*//p' "$HERMES_CONF" | head -1 | tr -d "\"'" | tr -d '[:space:]')"
pc_shell(){ # $1 = PowerShell-Kommando
python3 - "$PC_URL" "$PC_TOKEN" "$1" <<'PY'
import json, sys, urllib.request
url, token, cmd = sys.argv[1:4]
req = urllib.request.Request(url + "/shell", data=json.dumps({"command": cmd}).encode(),
headers={"Authorization": "Bearer " + token,
"Content-Type": "application/json"})
try:
r = json.load(urllib.request.urlopen(req, timeout=58))
except Exception as e:
print(f"(PC-Executor nicht erreichbar: {e})")
sys.exit(2)
out = (r.get("stdout") or "").strip()
if out:
print(out)
err = (r.get("stderr") or "").strip()
if err:
print(err, file=sys.stderr)
sys.exit(0 if r.get("returncode") == 0 else 1)
PY
}
cleanup_wt(){ git -C "$LUCY" worktree remove --force "$WT" 2>/dev/null || true; }
fail(){
status "fehlgeschlagen" "$1"
notify "Lucy-Vorschlag '$BRANCH' konnte NICHT eingespielt werden: $1 — nichts wurde verändert, der Branch bleibt liegen."
cleanup_wt
exit 1
}
# Revert nach schon gepushtem Merge (PC-Phase rot): main zurückdrehen, PC-Checkout nachziehen.
revert_main(){ # $1 = Grund für die Meldung
status "rollback" "PC-Phase rot — Merge wird automatisch zurückgedreht"
git -C "$LUCY" fetch -q origin || true
cleanup_wt
if git -C "$LUCY" worktree add --detach "$WT" origin/main >/dev/null 2>&1 \
&& git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
revert -m 1 --no-edit HEAD >>"$LOG" 2>&1 \
&& git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1; then
cleanup_wt
# PC-Checkout best-effort auf den revertierten Stand nachziehen (Quelle = laufende Lucy).
pc_shell "git -C '$PC_REPO' fetch origin main 2>&1; git -C '$PC_REPO' merge --ff-only FETCH_HEAD 2>&1" >>"$LOG" 2>&1 || true
status "zurueckgerollt" "$1 — Merge automatisch revertiert, Lucy läuft in der alten Version weiter"
notify "Lucy-Vorschlag '$BRANCH' hat den PC-Build/Neustart GERISSEN: $1. Merge automatisch zurückgedreht — Lucy läuft in der alten Version weiter. Der Branch bleibt zur Analyse liegen. Log: $LOG"
exit 1
fi
cleanup_wt
status "kritisch" "Revert fehlgeschlagen — Lucy-Repo und PC von Hand prüfen"
notify "KRITISCH: Lucy-Annahme '$BRANCH' scheiterte UND der automatische Revert schlug fehl — bitte Lucy-Repo (Gitea main) und PC prüfen. Log: $LOG"
exit 2
}
[ -n "$PC_URL" ] && [ -n "$PC_TOKEN" ] || fail "PC-Executor-Zugang fehlt in $HERMES_CONF (PC_EXECUTOR_URL/TOKEN)"
# ── Vorprüfungen am PC (VOR dem Merge — solange ist nichts kaputtzumachen) ──
status "laeuft" "Vorprüfung: ist der PC bereit?"
curl -sf -m 8 "$PC_URL/health" >/dev/null 2>&1 \
|| fail "PC-Executor nicht erreichbar — Lucy-Annahme braucht den eingeschalteten PC (Executor-Task läuft bei Anmeldung)"
PC_BRANCH="$(pc_shell "git -C '$PC_REPO' rev-parse --abbrev-ref HEAD" 2>>"$LOG")" \
|| fail "PC-Arbeitskopie nicht lesbar (git am PC): $PC_BRANCH"
[ "$PC_BRANCH" = "main" ] || fail "PC-Arbeitskopie steht auf '$PC_BRANCH' statt main — bitte am PC aufräumen"
PC_DIRTY="$(pc_shell "git -C '$PC_REPO' status --porcelain -uno" 2>>"$LOG")" \
|| fail "PC-Arbeitskopie nicht prüfbar"
[ -z "$PC_DIRTY" ] || fail "PC-Arbeitskopie hat ungespeicherte Änderungen — bitte erst am PC committen/aufräumen: $(echo "$PC_DIRTY" | head -3 | tr '\n' ' ')"
WAS_RUNNING="$(pc_shell "if (Get-Process Lucy -ErrorAction SilentlyContinue) { '1' } else { '0' }" 2>>"$LOG")" || WAS_RUNNING="0"
case "$WAS_RUNNING" in 1|0) ;; *) WAS_RUNNING="0" ;; esac
# ── Merge auf main (Box, isolierter Worktree — wie beim MC2-Runner) ─────────
status "laeuft" "Merge wird vorbereitet"
git -C "$LUCY" fetch -q origin || fail "git fetch (Gitea) fehlgeschlagen"
git -C "$LUCY" rev-parse --verify -q "refs/remotes/origin/$BRANCH" >/dev/null \
|| fail "Branch origin/$BRANCH existiert nicht (schon gemergt/gelöscht?)"
cleanup_wt
git -C "$LUCY" worktree add --detach "$WT" origin/main >/dev/null 2>&1 || fail "Worktree konnte nicht angelegt werden"
if ! git -C "$WT" -c user.name="Auftragsbuch" -c user.email="auftragsbuch@box.local" \
merge --no-ff "origin/$BRANCH" -m "Auftragsbuch: Lucy-Vorschlag '$BRANCH' angenommen (Ein-Klick-Gate)" >>"$LOG" 2>&1; then
git -C "$WT" merge --abort 2>/dev/null || true
fail "Merge-Konflikt mit main — der Branch ist veraltet, bitte am PC auflösen"
fi
status "laeuft" "Push nach main läuft"
PUSHED=0
for _ in 1 2 3; do
git -C "$WT" push origin HEAD:main >>"$LOG" 2>&1 && { PUSHED=1; break; }
sleep 5
done
[ "$PUSHED" = 1 ] || fail "Push nach main fehlgeschlagen (Gitea nicht erreichbar / Token?)"
cleanup_wt
# ── PC: main ziehen, Build detached starten ─────────────────────────────────
status "laeuft" "PC zieht den neuen Stand"
pc_shell "git -C '$PC_REPO' fetch origin main 2>&1; if (\$LASTEXITCODE -ne 0) { exit 1 }; git -C '$PC_REPO' merge --ff-only FETCH_HEAD 2>&1" >>"$LOG" 2>&1 \
|| revert_main "PC konnte main nicht ziehen (git fetch/merge am PC rot)"
pc_shell "if (Test-Path '$PC_RUNNER') { exit 0 } else { exit 1 }" >>"$LOG" 2>&1 \
|| revert_main "PC-Runner-Skript fehlt im Lucy-Repo (deploy/lucy-annahme.ps1)"
pc_shell "Remove-Item -Force '$PC_STATUSFILE' -ErrorAction SilentlyContinue; Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','$PC_RUNNER','-WasRunning','$WAS_RUNNING'" >>"$LOG" 2>&1 \
|| revert_main "PC-Build ließ sich nicht starten"
# ── Status-Datei pollen, Fortschritt in die UI spiegeln ─────────────────────
status "laeuft" "PC baut Lucy (dist) — das dauert ein paar Minuten"
DEADLINE=$(( $(date +%s) + BUILD_TIMEOUT_S ))
LAST_DETAIL=""
FINAL=""
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
sleep 10
RAW="$(pc_shell "if (Test-Path '$PC_STATUSFILE') { Get-Content -Raw '$PC_STATUSFILE' }" 2>>"$LOG")" || continue
[ -n "$RAW" ] || continue
PARSED="$(printf '%s' "$RAW" | python3 -c '
import json, sys
try:
d = json.loads(sys.stdin.read())
except Exception:
sys.exit(0)
detail = str(d.get("detail", "")).replace("|", "/").replace("\n", " ")
print(f"{1 if d.get(\"done\") else 0}|{1 if d.get(\"ok\") else 0}|{detail}")
')"
[ -n "$PARSED" ] || continue
DONE="${PARSED%%|*}"; REST="${PARSED#*|}"; OK="${REST%%|*}"; DETAIL="${REST#*|}"
if [ "$DETAIL" != "$LAST_DETAIL" ] && [ -n "$DETAIL" ]; then
LAST_DETAIL="$DETAIL"
status "laeuft" "PC: $DETAIL"
fi
if [ "$DONE" = "1" ]; then FINAL="$OK"; break; fi
done
[ -n "$FINAL" ] || revert_main "PC-Build-Timeout nach $((BUILD_TIMEOUT_S/60)) Minuten (Status-Datei blieb offen)"
[ "$FINAL" = "1" ] || revert_main "${LAST_DETAIL:-PC meldet Fehler ohne Detail}"
# ── Grün: Branch aufräumen, ehrlich melden ──────────────────────────────────
git -C "$LUCY" push origin --delete "$BRANCH" >>"$LOG" 2>&1 || true
git -C "$LUCY" fetch -q --prune origin 2>/dev/null || true
status "eingespielt" "${LAST_DETAIL:-Build grün}"
if [ "$WAS_RUNNING" = "1" ]; then
notify "Lucy-Vorschlag '$BRANCH' ist LIVE — dist am PC neu gebaut, Lucy neu gestartet, Stimme meldet sich. ✅"
else
notify "Lucy-Vorschlag '$BRANCH' ist eingespielt — dist am PC neu gebaut. Lucy war aus und bleibt aus; die neue Version startet beim nächsten Öffnen. ✅"
fi
exit 0
+3
View File
@@ -18,6 +18,9 @@ sauberes Handwerk.
4. Gate vor dem Push: `python3 -m py_compile` für JEDE geänderte .py-Datei. Frontend 4. Gate vor dem Push: `python3 -m py_compile` für JEDE geänderte .py-Datei. Frontend
(`frontend/`) nur anfassen, wenn der Auftrag es verlangt — die Box kann kein (`frontend/`) nur anfassen, wenn der Auftrag es verlangt — die Box kann kein
`npm build`; das ehrlich in Commit-Text und Summary sagen. `npm build`; das ehrlich in Commit-Text und Summary sagen.
Lucy-Aufträge (Repo `lucy`) genauso als Vorschlags-Branch pushen: bauen, testen
und neu starten macht der PC automatisch, wenn der Commander die Karte annimmt —
du baust NICHT selbst und behauptest NICHT, gebaut zu haben.
5. TABU-Zonen: approvals/Tokens/ufw/Security-Configs, `~/.hermes/config.yaml`, 5. TABU-Zonen: approvals/Tokens/ufw/Security-Configs, `~/.hermes/config.yaml`,
systemd-Units, alles außerhalb deines Workspaces. Braucht der Auftrag so etwas → systemd-Units, alles außerhalb deines Workspaces. Braucht der Auftrag so etwas →
`kanban_block` mit Begründung, nicht selbst machen. `kanban_block` mit Begründung, nicht selbst machen.
+26 -4
View File
@@ -31,7 +31,9 @@ bewiesen; ein Eigenbau hätte all das nur nachgebaut.
- **Fertige Patches** — Branches auf Gitea (`wartung/*`, `orchestrator/*`, `doku/*`, - **Fertige Patches** — Branches auf Gitea (`wartung/*`, `orchestrator/*`, `doku/*`,
`feature/*`), wie die Werkstatt und der Orchestrator sie hinterlassen. Die Karte `feature/*`), wie die Werkstatt und der Orchestrator sie hinterlassen. Die Karte
zeigt Commit-Botschaft, geänderte Dateien und den vollen Diff. zeigt Commit-Botschaft, geänderte Dateien und den vollen Diff. Seit S3
(lucy-pipeline) aus **zwei Repos**: `mission-control-v2` (Box-Stack) und `lucy`
(Desktop-App, rosa „Lucy"-Badge auf der Karte).
- **Skill-Kandidaten** — Ideen des nächtlichen Traum-Crons aus dem Wissens-Vault - **Skill-Kandidaten** — Ideen des nächtlichen Traum-Crons aus dem Wissens-Vault
(`~/wissens-vault/skill-kandidaten/`). „Beauftragen" schickt sie als Auftrag an (`~/wissens-vault/skill-kandidaten/`). „Beauftragen" schickt sie als Auftrag an
die Werkstatt (Orchestrator-Skill); das Ergebnis kommt als neuer Patch zurück. die Werkstatt (Orchestrator-Skill); das Ergebnis kommt als neuer Patch zurück.
@@ -50,6 +52,26 @@ Der Lauf ist eine eigene systemd-Unit (detached) — er überlebt den Neustart d
Backends, das ihn gestartet hat. Fortschritt steht live auf der Karte Backends, das ihn gestartet hat. Fortschritt steht live auf der Karte
(`/srv/models/mc2-auftragsbuch.json`). (`/srv/models/mc2-auftragsbuch.json`).
## Was „Annehmen" bei einer LUCY-Karte macht (deploy/lucy-annahme.sh)
Lucy (Electron) lebt auf dem Windows-PC; ihr `dist` liegt nicht im Repo — die Box
kann sie nicht bauen. Der Lucy-Runner spannt darum den PC ein (PC-Executor :7777,
Token aus `~/.hermes/config.yaml`):
1. **Vorprüfung am PC, VOR dem Merge:** Executor erreichbar? Arbeitskopie
`F:\Coding Stuff\lucy` auf `main` und sauber? Läuft Lucy gerade? Scheitert etwas,
bricht der Lauf ab, ohne irgendetwas zu verändern — der PC muss an sein.
2. Merge des Branches im isolierten Worktree von `~/lucy` → Push nach `main`.
3. Der PC zieht `main` (ff-only) und startet **detached**
`deploy/lucy-annahme.ps1` (liegt im Lucy-Repo): ggf. `npm ci` (nur wenn sich
das Lockfile geändert hat) → `tsc --noEmit`-Gate → dist-Backup → `npm run dist`
→ Lucy-Neustart **nur, wenn sie vorher lief** (User-Entscheid: Lucy startet
nur, wenn der Commander sie will) → Stimme-Health (`:8130`).
4. Die Box pollt die PC-Status-Datei (`.lucy-annahme.json`) und spiegelt den
Fortschritt live auf die Karte. **Rot ⇒ Merge wird automatisch revertiert**,
der PC stellt dist aus dem Backup wieder her — die laufende Lucy bleibt die
alte. Grün ⇒ Branch aufgeräumt, Meldung an Telegram + Chronik.
## Bagatellen ohne Klick (mc2-bagatell.timer, 04:10) ## Bagatellen ohne Klick (mc2-bagatell.timer, 04:10)
Einzige Ausnahme vom Klick-Gate (User-Entscheid 10.07.2026, bewusst eng geschnitten): Einzige Ausnahme vom Klick-Gate (User-Entscheid 10.07.2026, bewusst eng geschnitten):
@@ -65,9 +87,9 @@ erweitert nur der Commander, nie das Skript.
- Propose-only bleibt: **keine Code-Zeile geht ohne Klick live.** Das Gate ist dieses - Propose-only bleibt: **keine Code-Zeile geht ohne Klick live.** Das Gate ist dieses
Buch; einzige Ausnahme sind die eng geschnittenen Doku-Bagatellen (oben). Buch; einzige Ausnahme sind die eng geschnittenen Doku-Bagatellen (oben).
- „Frontend ohne Build"-Warnung: die Box kann kein Frontend bauen (kein Node) — - „Frontend ohne Build"-Warnung (nur mc2): enthält ein Branch `frontend/src` ohne
enthält ein Branch `frontend/src` ohne frisches `frontend/dist`, bleibt die frisches `frontend/dist`, bleibt die Oberfläche nach dem Annehmen alt, bis am PC
Oberfläche nach dem Annehmen alt, bis am PC gebaut wird. gebaut wird. Lucy-Karten brauchen die Warnung nicht — dort baut IMMER der PC.
- Ablehnen löscht nur den Remote-Branch; die Historie auf Gitea bleibt. - Ablehnen löscht nur den Remote-Branch; die Historie auf Gitea bleibt.
Diese Datei kam übrigens selbst über das Auftragsbuch ins Repo — als erste echte Diese Datei kam übrigens selbst über das Auftragsbuch ins Repo — als erste echte
+14 -8
View File
@@ -1,6 +1,6 @@
# Offene Fäden — DIE eine Liste # Offene Fäden — DIE eine Liste
_Stand 10.07.2026 (Übergabe-Session S1). Regel: Neues hier rein, Erledigtes hier raus, _Stand 12.07.2026 (Session S3 lucy-pipeline). Regel: Neues hier rein, Erledigtes hier raus,
keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VERDIKTE.md](VERDIKTE.md)._ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VERDIKTE.md](VERDIKTE.md)._
## Termine (auch als Box-Reminder hinterlegt) ## Termine (auch als Box-Reminder hinterlegt)
@@ -16,13 +16,16 @@ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VE
## Das Ablösungs-Paket (Kurs, siehe [ZIELBILD.md](ZIELBILD.md)) ## Das Ablösungs-Paket (Kurs, siehe [ZIELBILD.md](ZIELBILD.md))
- **S2 „leg los queue":** Kanban-Probe (`auto_decompose` liegt ungenutzt in der Config) → - **S2 „leg los queue" ✅ (10.+12.07.):** Queue = natives Hermes-Kanban, Türen live
Queue-Entscheid nativ vs. Auftragsbuch-Ausbau → Türen verdrahten (Telegram/Desktop-Idee → (Zentrale/Telegram/Lucy), Bagatell-Pfad enabled; voller Kreislauf einmal komplett
Queue) → Morgenlage meldet Queue-Stand → Bagatell-Nacht-Deploy-Pfad. durchlaufen (`wartung/fix-double-zero-self-repair`). Rest: Live-Telegram-Test beim
- **S3 „leg los lucy-pipeline":** PC-Annahme-Weg (Auftragsbuch-Gegenstück für Lucy: nächsten echten Zuruf beobachten; Specifier schreibt Titel englisch (ggf. tunen).
Karte → Klick = dist-Build + Neustart am PC) bauen + E2E-Beweis. Erster echter Auftrag: - **S3 „leg los lucy-pipeline" (12.07., diese Karte):** PC-Annahme-Weg gebaut —
**A4-Turncheck-Auswertung** (`C:\Users\TobisPC\lucy-turncheck.log` sammelt seit 07.07.; Auftragsbuch liest jetzt ZWEI Repos (mc2 + lucy), Lucy-Annahme = Merge auf der Box +
danach HOLD_BELOW_P justieren). dist-Build/Neustart am PC via PC-Executor (`deploy/lucy-annahme.sh` +
`deploy/lucy-annahme.ps1` im Lucy-Repo), Auto-Revert bei Rot. Erster echter Auftrag:
A4-Turncheck-Auswertung (Verdikt aus 680 echten Entscheidungen: Default AUS, opt-in) —
liegt als Lucy-Karte bereit. **E2E-Beweis = Annahme-Klick der beiden Karten.**
- **S4 „leg los zuendung":** Idle-Radar / Fehler→Auftrag (Traum-Funde + Journal-Muster → - **S4 „leg los zuendung":** Idle-Radar / Fehler→Auftrag (Traum-Funde + Journal-Muster →
Queue-Kandidaten) · Großbau-Etappen-Regeln in Skills · Generalprobe ohne Claude. Queue-Kandidaten) · Großbau-Etappen-Regeln in Skills · Generalprobe ohne Claude.
@@ -36,6 +39,9 @@ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VE
aufräumen nur mit User-Ja (v2-Pipeline ersetzt sie). aufräumen nur mit User-Ja (v2-Pipeline ersetzt sie).
- „Lucy light"-Experiment: ungestartet, Idee geparkt. - „Lucy light"-Experiment: ungestartet, Idee geparkt.
- Frechheit-Regler Phase 3 ist LIVE; weitere Animations-Wünsche nur auf Zuruf. - Frechheit-Regler Phase 3 ist LIVE; weitere Animations-Wünsche nur auf Zuruf.
- A4-Turncheck: nach dem Default-AUS (S3) gilt — wer ihn wieder will, setzt
`localStorage.lucy_turncheck="1"`; Telemetrie loggt dann nur noch in die Konsole
(`lucy_perf="1"`), die Log-Datei `lucy-turncheck.log` wird nicht mehr beschrieben.
- Emotions-Stimme = Zukunftsfaden (Klon-DNA ist ruhig; warten auf Qwen-instruct-Clone o. ä.). - Emotions-Stimme = Zukunftsfaden (Klon-DNA ist ruhig; warten auf Qwen-instruct-Clone o. ä.).
## Box / MC2 (Kleinkram + Politur) ## Box / MC2 (Kleinkram + Politur)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BRolD2Rb.js"></script> <script type="module" crossorigin src="/assets/index-ZY9WIA27.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSNo2FSN.css"> <link rel="stylesheet" crossorigin href="/assets/index-Be3zHdDf.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1
View File
@@ -448,6 +448,7 @@ export interface AuftragStatus {
ts: number ts: number
} }
export interface AuftragItem { export interface AuftragItem {
repo?: "mc2" | "lucy" // Quelle: Box-Stack (mc2) oder Lucy-Desktop (Annahme baut am PC)
branch: string branch: string
kind: string // wartung | orchestrator | doku | feature kind: string // wartung | orchestrator | doku | feature
subject: string subject: string
+21 -9
View File
@@ -104,7 +104,7 @@ export function AuftragsbuchView() {
<section className="space-y-3"> <section className="space-y-3">
<SectionLabel icon={GitBranch}>Fertige Patches ({items.length})</SectionLabel> <SectionLabel icon={GitBranch}>Fertige Patches ({items.length})</SectionLabel>
{items.map((it) => ( {items.map((it) => (
<ProposalCard key={it.branch} item={it} busy={busy} onAct={act} showConfirm={showConfirm} /> <ProposalCard key={`${it.repo ?? "mc2"}:${it.branch}`} item={it} busy={busy} onAct={act} showConfirm={showConfirm} />
))} ))}
</section> </section>
)} )}
@@ -285,6 +285,10 @@ function ProposalCard({
const [diffLoading, setDiffLoading] = useState(false) const [diffLoading, setDiffLoading] = useState(false)
const running = item.status?.state === "laeuft" || item.status?.state === "rollback" const running = item.status?.state === "laeuft" || item.status?.state === "rollback"
const decided = item.status?.state === "eingespielt" const decided = item.status?.state === "eingespielt"
// Lucy-Karten (Desktop-App): Annehmen baut am PC (dist) statt auf der Box zu deployen.
const repo = item.repo ?? "mc2"
const isLucy = repo === "lucy"
const k = `${repo}:${item.branch}`
async function loadDiff() { async function loadDiff() {
const next = !open const next = !open
@@ -292,7 +296,7 @@ function ProposalCard({
if (next && diff === null) { if (next && diff === null) {
setDiffLoading(true) setDiffLoading(true)
try { try {
const r = await api<{ diff: string; truncated: boolean }>(`/api/auftragsbuch/diff?branch=${encodeURIComponent(item.branch)}`) const r = await api<{ diff: string; truncated: boolean }>(`/api/auftragsbuch/diff?branch=${encodeURIComponent(item.branch)}&repo=${repo}`)
setDiff(r.diff + (r.truncated ? "\n (gekürzt)" : "")) setDiff(r.diff + (r.truncated ? "\n (gekürzt)" : ""))
} catch (e: any) { } catch (e: any) {
setDiff(`(Diff nicht ladbar: ${e?.message || e})`) setDiff(`(Diff nicht ladbar: ${e?.message || e})`)
@@ -310,6 +314,12 @@ function ProposalCard({
<span className="rounded-md border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary"> <span className="rounded-md border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary">
{KIND_LABEL[item.kind] ?? item.kind} {KIND_LABEL[item.kind] ?? item.kind}
</span> </span>
{isLucy && (
<span className="rounded-md border border-pink-500/40 bg-pink-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-pink-300"
title="Vorschlag fürs Lucy-Repo Annehmen baut die App am PC neu und startet sie neu, wenn sie läuft.">
Lucy
</span>
)}
<span className="font-mono text-[11px] text-muted-foreground truncate" title={item.branch}>{item.branch}</span> <span className="font-mono text-[11px] text-muted-foreground truncate" title={item.branch}>{item.branch}</span>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(item.ts)}</span> <span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(item.ts)}</span>
<StateBadge item={item} /> <StateBadge item={item} />
@@ -321,22 +331,24 @@ function ProposalCard({
{!running && !decided && ( {!running && !decided && (
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<button <button
disabled={!!busy[item.branch]} disabled={!!busy[k]}
onClick={() => onClick={() =>
showConfirm("Vorschlag annehmen?", showConfirm("Vorschlag annehmen?",
`'${item.branch}' wird auf main gemergt und live eingespielt. Die Zentrale startet dabei kurz neu. Geht der Health-Check danach rot, rollt die Box automatisch zurück.${item.frontend_ohne_build ? "\n\nAchtung: Enthält Frontend-Quelltext ohne gebautes Bundle die Oberfläche bleibt alt, bis am PC gebaut wird." : ""}`, isLucy
() => onAct(item.branch, "/api/auftragsbuch/annehmen", { branch: item.branch }))} ? `'${item.branch}' wird im Lucy-Repo auf main gemergt, dann baut dein PC die App neu (dist) und startet Lucy neu, falls sie gerade läuft. Der PC muss dafür an sein. Schlägt Build oder Start fehl, dreht die Box den Merge automatisch zurück — die laufende Lucy bleibt die alte.`
: `'${item.branch}' wird auf main gemergt und live eingespielt. Die Zentrale startet dabei kurz neu. Geht der Health-Check danach rot, rollt die Box automatisch zurück.${item.frontend_ohne_build ? "\n\nAchtung: Enthält Frontend-Quelltext ohne gebautes Bundle die Oberfläche bleibt alt, bis am PC gebaut wird." : ""}`,
() => onAct(k, "/api/auftragsbuch/annehmen", { branch: item.branch, repo }))}
className="flex h-9 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50"> className="flex h-9 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
{busy[item.branch] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} Annehmen {busy[k] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} Annehmen
</button> </button>
<button <button
disabled={!!busy["ab:" + item.branch]} disabled={!!busy["ab:" + k]}
onClick={() => onClick={() =>
showConfirm("Vorschlag ablehnen?", showConfirm("Vorschlag ablehnen?",
`Der Branch '${item.branch}' wird auf Gitea gelöscht. Die Arbeit bleibt in der Historie, aber die Karte verschwindet.`, `Der Branch '${item.branch}' wird auf Gitea gelöscht. Die Arbeit bleibt in der Historie, aber die Karte verschwindet.`,
() => onAct("ab:" + item.branch, "/api/auftragsbuch/ablehnen", { branch: item.branch }))} () => onAct("ab:" + k, "/api/auftragsbuch/ablehnen", { branch: item.branch, repo }))}
className="flex h-9 items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300/90 transition-all hover:bg-red-500/15 cursor-pointer disabled:opacity-50"> className="flex h-9 items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300/90 transition-all hover:bg-red-500/15 cursor-pointer disabled:opacity-50">
{busy["ab:" + item.branch] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />} Ablehnen {busy["ab:" + k] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />} Ablehnen
</button> </button>
</div> </div>
)} )}