Auftragsbuch: 'feature/lucy-pipeline' angenommen (Ein-Klick-Gate)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -18,6 +18,9 @@ sauberes Handwerk.
|
||||
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
|
||||
`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`,
|
||||
systemd-Units, alles außerhalb deines Workspaces. Braucht der Auftrag so etwas →
|
||||
`kanban_block` mit Begründung, nicht selbst machen.
|
||||
|
||||
+26
-4
@@ -31,7 +31,9 @@ bewiesen; ein Eigenbau hätte all das nur nachgebaut.
|
||||
|
||||
- **Fertige Patches** — Branches auf Gitea (`wartung/*`, `orchestrator/*`, `doku/*`,
|
||||
`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
|
||||
(`~/wissens-vault/skill-kandidaten/`). „Beauftragen" schickt sie als Auftrag an
|
||||
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
|
||||
(`/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)
|
||||
|
||||
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
|
||||
Buch; einzige Ausnahme sind die eng geschnittenen Doku-Bagatellen (oben).
|
||||
- „Frontend ohne Build"-Warnung: die Box kann kein Frontend bauen (kein Node) —
|
||||
enthält ein Branch `frontend/src` ohne frisches `frontend/dist`, bleibt die
|
||||
Oberfläche nach dem Annehmen alt, bis am PC gebaut wird.
|
||||
- „Frontend ohne Build"-Warnung (nur mc2): enthält ein Branch `frontend/src` ohne
|
||||
frisches `frontend/dist`, bleibt die Oberfläche nach dem Annehmen alt, bis am PC
|
||||
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.
|
||||
|
||||
Diese Datei kam übrigens selbst über das Auftragsbuch ins Repo — als erste echte
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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)._
|
||||
|
||||
## 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))
|
||||
|
||||
- **S2 „leg los queue":** Kanban-Probe (`auto_decompose` liegt ungenutzt in der Config) →
|
||||
Queue-Entscheid nativ vs. Auftragsbuch-Ausbau → Türen verdrahten (Telegram/Desktop-Idee →
|
||||
Queue) → Morgenlage meldet Queue-Stand → Bagatell-Nacht-Deploy-Pfad.
|
||||
- **S3 „leg los lucy-pipeline":** PC-Annahme-Weg (Auftragsbuch-Gegenstück für Lucy:
|
||||
Karte → Klick = dist-Build + Neustart am PC) bauen + E2E-Beweis. Erster echter Auftrag:
|
||||
**A4-Turncheck-Auswertung** (`C:\Users\TobisPC\lucy-turncheck.log` sammelt seit 07.07.;
|
||||
danach HOLD_BELOW_P justieren).
|
||||
- **S2 „leg los queue" ✅ (10.+12.07.):** Queue = natives Hermes-Kanban, Türen live
|
||||
(Zentrale/Telegram/Lucy), Bagatell-Pfad enabled; voller Kreislauf einmal komplett
|
||||
durchlaufen (`wartung/fix-double-zero-self-repair`). Rest: Live-Telegram-Test beim
|
||||
nächsten echten Zuruf beobachten; Specifier schreibt Titel englisch (ggf. tunen).
|
||||
- **S3 „leg los lucy-pipeline" (12.07., diese Karte):** PC-Annahme-Weg gebaut —
|
||||
Auftragsbuch liest jetzt ZWEI Repos (mc2 + lucy), Lucy-Annahme = Merge auf der Box +
|
||||
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 →
|
||||
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).
|
||||
- „Lucy light"-Experiment: ungestartet, Idee geparkt.
|
||||
- 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. ä.).
|
||||
|
||||
## Box / MC2 (Kleinkram + Politur)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+45
-45
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-BRolD2Rb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BSNo2FSN.css">
|
||||
<script type="module" crossorigin src="/assets/index-ZY9WIA27.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Be3zHdDf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -448,6 +448,7 @@ export interface AuftragStatus {
|
||||
ts: number
|
||||
}
|
||||
export interface AuftragItem {
|
||||
repo?: "mc2" | "lucy" // Quelle: Box-Stack (mc2) oder Lucy-Desktop (Annahme baut am PC)
|
||||
branch: string
|
||||
kind: string // wartung | orchestrator | doku | feature
|
||||
subject: string
|
||||
|
||||
@@ -104,7 +104,7 @@ export function AuftragsbuchView() {
|
||||
<section className="space-y-3">
|
||||
<SectionLabel icon={GitBranch}>Fertige Patches ({items.length})</SectionLabel>
|
||||
{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>
|
||||
)}
|
||||
@@ -285,6 +285,10 @@ function ProposalCard({
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
const running = item.status?.state === "laeuft" || item.status?.state === "rollback"
|
||||
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() {
|
||||
const next = !open
|
||||
@@ -292,7 +296,7 @@ function ProposalCard({
|
||||
if (next && diff === null) {
|
||||
setDiffLoading(true)
|
||||
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)" : ""))
|
||||
} catch (e: any) {
|
||||
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">
|
||||
{KIND_LABEL[item.kind] ?? item.kind}
|
||||
</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="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(item.ts)}</span>
|
||||
<StateBadge item={item} />
|
||||
@@ -321,22 +331,24 @@ function ProposalCard({
|
||||
{!running && !decided && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
disabled={!!busy[item.branch]}
|
||||
disabled={!!busy[k]}
|
||||
onClick={() =>
|
||||
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." : ""}`,
|
||||
() => onAct(item.branch, "/api/auftragsbuch/annehmen", { branch: item.branch }))}
|
||||
isLucy
|
||||
? `'${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">
|
||||
{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
|
||||
disabled={!!busy["ab:" + item.branch]}
|
||||
disabled={!!busy["ab:" + k]}
|
||||
onClick={() =>
|
||||
showConfirm("Vorschlag ablehnen?",
|
||||
`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">
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user