#!/usr/bin/env python3 """Tailnet-only kanban board: static frontend + trigger API for the intent lifecycle pilot (docs/intent/kanban-board-admin-trigger.md). Cards backed by a docs/intent/*.md file default to phase "geplant" until a board move records a different phase in BOARD_STATE_FILE. Manually created "idea" cards live only in that state file. Moving an intent-backed card from "geplant" to "in_umsetzung" is the one active transition: it queues a job for ../board-agent/worker.py (systemd-path-triggered, see ../board-agent.nix) instead of just recording the move. Every other move, for every card, is plain bookkeeping with no side effect -- see the intent document's explicit scope limits. """ import json import state_store import workflow import os import re import sys import threading import time import uuid from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlsplit import fanout import metrics import orchestration import telemetry SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,60}$") CARD_ID_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,80}") SUPPORTED_RUNTIMES = ("codex", "claude", "openrouter") COLUMN_CONFIG_DEFAULTS = { "in_umsetzung": { "runtime": "codex", "model": "", "reasoning_effort": "", "iteration_limit": 20, }, # The board's "leicht" lane -- its own dedicated agent-codex-builder-2 # instance/inbox (see docs/intent/board-builder-weighted-lanes.md), # defaulting to a cheap OpenRouter model instead of "in_umsetzung"'s # Codex default. Same config shape/API, just a second column. "in_umsetzung_leicht": { "runtime": "openrouter", "model": "deepseek/deepseek-v3.2", "reasoning_effort": "", "iteration_limit": 20, }, "review": {"runtime": "codex", "model": "", "reasoning_effort": ""}, "recherche_laeuft": {"runtime": "codex", "model": "", "reasoning_effort": ""}, "operator_angefordert": {"runtime": "codex", "model": "", "reasoning_effort": ""}, "klaerung_reparatur": { "runtime": "codex", "model": "", "reasoning_effort": "", "iteration_limit": 20, }, } MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+/-]{0,127}$") REASONING_EFFORT_RE = re.compile(r"^[A-Za-z0-9_-]{1,32}$") PHASES = ( "idea", "geplant", "in_umsetzung", # The board's second, dedicated Builder column ("leicht") -- see # COLUMN_CONFIG_DEFAULTS and docs/intent/board-builder-weighted-lanes.md. # Same rank as "in_umsetzung" in PHASE_RANK below: both are valid, # parallel successors of "geplant". "in_umsetzung_leicht", "review", "bereit_fuer_deployment", "planung_mit_feedback", "deployed", "archiviert", # Manual escalation column for a card stuck at the Builder iteration # limit even after its one automatic retry -- see # docs/intent/board-builder-limit-reached-auto-split.md. Deliberately # NOT in PHASE_RANK (see update_phase(), same treatment as archiviert): # it is a sibling escalation path, not a further pipeline stage. # "aufgeteilt" (the split outcome) is intentionally absent here -- it is # a worker-only terminal phase, never a legal PATCH target from the API. "aufteilen_angefordert", "operator_angefordert", "operator_laeuft", "wartet_auf_kinder", "durch_kinder_blockiert", # Distinct from "durch_kinder_blockiert": this is set when the Operator # step itself never produced children -- either a considered # needs_human decision or the Operator job raising an exception -- so # "blocked by children" would be a lie (there are none). See # orchestration.apply_needs_human() and worker.py's operator except # branch. "operator_klaerung_noetig", "fixer_angefordert", "fixer_laeuft", "fixer_rueckfrage", "fixer_fehlgeschlagen", "klaerung_reparatur", "inspector_laeuft", "inspector_rueckfrage", "inspector_fehlgeschlagen", ) CLARIFICATION_TASK_TYPES = {"implementation_status", "route_next", "repair"} ROUTING_ACTION_PHASES = { "geplant", "in_umsetzung", "in_umsetzung_leicht", "review", "archiviert", "recherche_angefordert", "recherche_laeuft", } # Pipeline rank per phase, used only to reject forward moves that skip a # stage. bereit_fuer_deployment and planung_mit_feedback share a rank: both # are valid successors of review and valid predecessors of deployed (see # docs/intent/board-intent-detail-and-transition-guardrails.md). Backward # moves (new rank <= current rank) are never guarded. PHASE_RANK = { "idea": 0, "geplant": 1, "in_umsetzung": 2, "in_umsetzung_leicht": 2, "review": 3, "bereit_fuer_deployment": 4, "planung_mit_feedback": 4, "deployed": 5, } RESEARCH_PHASES = ( "recherche_angefordert", "recherche_laeuft", "recherche_fertig", "recherche_fehlgeschlagen", "archiviert", ) RESEARCH_PHASE_RANK = { "recherche_angefordert": 0, "recherche_laeuft": 1, "recherche_fertig": 2, "recherche_fehlgeschlagen": 2, } # Single-process ThreadingHTTPServer: this lock keeps a create/patch/delete # from interleaving with another one, which is what makes the "only from # geplant" trigger guard in update_phase() below actually safe against a # double-drag race instead of just usually working. STATE_LOCK = state_store.StateLock() def validate_runtime(runtime): value = str(runtime or "").strip().lower() if value not in SUPPORTED_RUNTIMES: raise ValueError("Runtime muss exakt codex, claude oder openrouter sein.") return value def board_runtime(): return validate_runtime(load_state().get("runtime", os.environ.get("BOARD_RUNTIME_DEFAULT", "codex"))) def set_board_runtime(runtime): runtime = validate_runtime(runtime) with STATE_LOCK: state = load_state() state["runtime"] = runtime save_state(state) return runtime def validate_column_config_value(column, payload): if column not in COLUMN_CONFIG_DEFAULTS: raise ValueError("unbekannte Spalte") if not isinstance(payload, dict): raise ValueError("body must be a JSON object") allowed = COLUMN_CONFIG_DEFAULTS[column] unknown = set(payload) - set(allowed) if unknown: raise ValueError(f"unbekanntes Feld: {sorted(unknown)[0]}") validated = {} if "runtime" in payload: validated["runtime"] = validate_runtime(payload["runtime"]) if "model" in payload: value = str(payload["model"]) if value and not MODEL_RE.fullmatch(value): raise ValueError("ungültiges model") if len(value) > 128: raise ValueError("ungültiges model") validated["model"] = value if "reasoning_effort" in payload: value = str(payload["reasoning_effort"]) if value and not REASONING_EFFORT_RE.fullmatch(value): raise ValueError("ungültiges reasoning_effort") validated["reasoning_effort"] = value if "iteration_limit" in payload: value = payload["iteration_limit"] if isinstance(value, bool) or not isinstance(value, int) or not 5 <= value <= 40: raise ValueError("iteration_limit muss eine Ganzzahl zwischen 5 und 40 sein") validated["iteration_limit"] = value return validated def load_column_config(): stored = load_state().get("column_config", {}) result = {} for column, defaults in COLUMN_CONFIG_DEFAULTS.items(): values = stored.get(column, {}) if isinstance(stored, dict) else {} result[column] = { field: values.get(field, default) for field, default in defaults.items() } return result def set_column_config(column, updates): validated = validate_column_config_value(column, updates) with STATE_LOCK: state = load_state() config = load_column_config() config[column].update(validated) state["column_config"] = config save_state(state) return config[column] def now_iso(): return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def state_file(): return Path(os.environ["BOARD_STATE_FILE"]) def load_state(): return state_store.load() def save_state(data): with state_store.transaction(): state_store.save(data) def intent_title(path): try: for line in path.read_text(encoding="utf-8").splitlines(): stripped = line.strip() if stripped.startswith("# "): return stripped[2:].strip() except OSError: pass return path.stem def list_intent_cards(): intent_dir = Path(os.environ["BOARD_INTENT_DIR"]) cards = {} if not intent_dir.is_dir(): return cards for md in sorted(intent_dir.glob("*.md")): slug = md.stem if not SLUG_RE.match(slug): continue cards[slug] = { "id": slug, "source": "intent", "title": intent_title(md), "intent_path": f"docs/intent/{md.name}", } return cards def list_research_cards(): research_dir = Path( os.environ.get( "BOARD_RESEARCH_INTENT_DIR", str(Path(os.environ["BOARD_INTENT_DIR"]).parent / "research-intent"), ) ) cards = {} if not research_dir.is_dir(): return cards for md in sorted(research_dir.glob("*.md")): slug = md.stem if not SLUG_RE.match(slug): continue cards[slug] = { "id": slug, "source": "research", "title": intent_title(md), "intent_path": f"docs/research-intent/{md.name}", } return cards def stored_intent_card(card_id, stored): """Return a validated state-only card for an intent whose file is absent.""" slug = card_id path = f"docs/intent/{slug}.md" if ( stored.get("source") != "intent" or not SLUG_RE.fullmatch(slug) ): return None return { "id": slug, "source": "intent", "title": str(stored.get("title") or slug), "intent_path": path, "source_missing": True, } def escalation_exhausted(stored): """True only when the most recent Builder limit is terminal.""" return ( stored.get("last_run_status") == "limit_reached" and stored.get("auto_retry_attempts", 0) >= len(orchestration.escalation_ladder_for(stored)) ) def is_family_parent(stored): """Recognize a family parent even after clarification cleared blockers.""" return bool( stored.get("orchestration_kind") == "operator" or stored.get("child_slugs") or stored.get("depends_on") or stored.get("blocking_children") ) def repair_eligible(stored): """Keep expected/in-flight Builder escalation out of the Fixer path.""" failed_status = stored.get("last_run_status") in { "failed", "check_failed", "deploy_failed", } return ( int(stored.get("fixer_attempts", 0)) < 2 and not is_family_parent(stored) and ( stored.get("review_decision") == "planung_mit_feedback" or stored.get("phase") in { "planung_mit_feedback", "fixer_fehlgeschlagen", "inspector_fehlgeschlagen", } or failed_status or escalation_exhausted(stored) ) ) def available_actions(card_id, source, stored, job_active=False): phase = stored.get("phase", "recherche_angefordert" if source == "research" else "geplant" if source == "intent" else "idea") if job_active: return [] actions = [] def action(id, label, phase, **extra): actions.append({"id": id, "label": label, "phase": phase, **extra}) if phase == "archiviert": action("reactivate", "Reactivate", "recherche_angefordert" if source == "research" else "geplant" if source == "intent" else "idea") return actions if source == "idea": actions.append({"id": "plan", "label": "Refine with Planner", "endpoint": "plan"}) if source == "intent" and phase == "geplant" and not stored.get("blocked_by"): if not stored.get("operator_generated") and SLUG_RE.fullmatch(f"{card_id}-synthese"): action("operator", "Ask Operator", "operator_angefordert") action("builder", "Start Builder", "in_umsetzung") action("builder-light", "Start Builder (light)", "in_umsetzung_leicht") if source == "research" and phase == "recherche_angefordert": action("research", "Start Research", "recherche_laeuft") if stored.get("failure_stage") == "deployment" and stored.get("review_decision") == "bereit_fuer_deployment" and stored.get("commit_hash"): actions.append({"id": "retry-deployment", "label": "Retry deployment", "endpoint": "retry-deployment"}) if phase != "bereit_fuer_deployment" and not workflow.verified_deployment(stored): action("diagnose", "Diagnose", "klaerung_reparatur", task_type="implementation_status") action("route", "Choose next step", "klaerung_reparatur", task_type="route_next") if repair_eligible(stored) and stored.get("failure_stage") not in {"inspector", "pre_start", "review_config", "publication", "deployment"}: action("repair", "Repair", "klaerung_reparatur", task_type="repair") if source == "intent" and escalation_exhausted(stored) and not stored.get("operator_generated"): action("split", "Split", "aufteilen_angefordert") if source == "intent" and stored.get("commit_hash") and stored.get("last_run_status") in {"failed", "check_failed"}: actions.append({"id": "retry-review", "label": "Retry Review", "endpoint": "retry-review"}) if source == "research" and phase != "recherche_angefordert": action("reset", "Return to research queue", "recherche_angefordert") elif source == "intent" and phase != "geplant": action("reset", "Return to Ready", "geplant") action("archive", "Archive", "archiviert") return actions def merged_board(): with STATE_LOCK: state = load_state() intent_cards = list_intent_cards() research_cards = list_research_cards() board = [] job_active = ( repo_job_active() if all(os.environ.get(name) for name in ("BOARD_INBOX_DIR", "BOARD_PROCESSING_DIR")) else False ) seen = set() for slug, card in intent_cards.items(): stored = state["cards"].get(slug, {}) board.append( { **card, "phase": stored.get("phase", "geplant"), "updated_at": stored.get("updated_at"), "last_job_id": stored.get("last_job_id"), "last_run_status": stored.get("last_run_status"), "last_run_at": stored.get("last_run_at"), "review_decision": stored.get("review_decision"), "review_report": stored.get("review_report"), "commit_hash": stored.get("commit_hash"), "builder_report": stored.get("builder_report"), "failure_stage": stored.get("failure_stage"), "failure_detail": stored.get("failure_detail"), "research_report": stored.get("research_report"), "parent_id": stored.get("parent_id"), "child_slugs": stored.get("child_slugs"), "depends_on": stored.get("depends_on"), "synthesis_generated": stored.get("synthesis_generated", False), "operator_decision": stored.get("operator_decision"), "operator_reason": stored.get("operator_reason"), "human_action": stored.get("human_action"), "operator_parent_id": stored.get("operator_parent_id"), "blocking_children": stored.get("blocking_children"), "blocked_by": stored.get("blocked_by"), "wave": stored.get("wave"), "recommended_lane": stored.get("recommended_lane"), "recommended_profile": stored.get("recommended_profile"), "lane": stored.get("lane"), "runtime": stored.get("runtime"), "model": stored.get("model"), "reasoning_effort": stored.get("reasoning_effort"), "synthesis_status": stored.get("synthesis_status"), "write_scope": stored.get("write_scope"), "acceptance_criteria": stored.get("acceptance_criteria"), "metrics_summary": metrics.summary(stored), "pipeline_run_id": stored.get("pipeline_run_id"), "auto_retry_attempts": stored.get("auto_retry_attempts", 0), "escalation_attempts": stored.get("escalation_attempts", []), "iteration_limit": stored.get("iteration_limit"), "available_actions": available_actions(slug, "intent", stored, job_active), "fixer_attempts": stored.get("fixer_attempts", 0), "fixer_question": stored.get("fixer_question"), "fixer_qa": stored.get("fixer_qa", []), "fixer_outcome": stored.get("fixer_outcome"), "fixer_target_phase": stored.get("fixer_target_phase"), "clarification_task_type": stored.get("clarification_task_type"), "clarification_question": stored.get("clarification_question") or stored.get("fixer_question"), "repo_status": stored.get("repo_status"), "repo_evidence": stored.get("repo_evidence"), "deployment_evidence": stored.get("deployment_evidence"), "routing_recommendation": stored.get("routing_recommendation"), "routing_reason": stored.get("routing_reason"), "routing_pending": stored.get("routing_pending", False), "deployment_status": stored.get("deployment_status"), "source_missing": False, } ) seen.add(slug) for slug, card in research_cards.items(): stored = state["cards"].get(slug, {}) board.append({ **card, "phase": stored.get("phase", "recherche_angefordert"), "updated_at": stored.get("updated_at"), "last_job_id": stored.get("last_job_id"), "last_run_status": stored.get("last_run_status"), "last_run_at": stored.get("last_run_at"), "failure_stage": stored.get("failure_stage"), "failure_detail": stored.get("failure_detail"), "research_report": stored.get("research_report"), "parent_id": stored.get("parent_id"), "depends_on": stored.get("depends_on"), "synthesis_generated": stored.get("synthesis_generated", False), "metrics_summary": metrics.summary(stored), "pipeline_run_id": stored.get("pipeline_run_id"), "auto_retry_attempts": stored.get("auto_retry_attempts", 0), "escalation_attempts": stored.get("escalation_attempts", []), "iteration_limit": stored.get("iteration_limit"), "available_actions": available_actions(slug, "research", stored, job_active), "fixer_attempts": stored.get("fixer_attempts", 0), "fixer_question": stored.get("fixer_question"), "fixer_qa": stored.get("fixer_qa", []), "fixer_target_phase": stored.get("fixer_target_phase"), "clarification_task_type": stored.get("clarification_task_type"), "clarification_question": stored.get("clarification_question") or stored.get("fixer_question"), "routing_recommendation": stored.get("routing_recommendation"), "routing_reason": stored.get("routing_reason"), "routing_pending": stored.get("routing_pending", False), "source_missing": False, }) seen.add(slug) for card_id, stored in state["cards"].items(): if card_id in seen: continue fallback = stored_intent_card(card_id, stored) if fallback: board.append( { **fallback, "phase": stored.get("phase", "geplant"), "updated_at": stored.get("updated_at"), "last_job_id": stored.get("last_job_id"), "last_run_status": stored.get("last_run_status"), "last_run_at": stored.get("last_run_at"), "review_decision": stored.get("review_decision"), "review_report": stored.get("review_report"), "commit_hash": stored.get("commit_hash"), "builder_report": stored.get("builder_report"), "failure_stage": stored.get("failure_stage"), "failure_detail": stored.get("failure_detail"), "parent_id": stored.get("parent_id"), "child_slugs": stored.get("child_slugs"), "depends_on": stored.get("depends_on"), "synthesis_generated": stored.get("synthesis_generated", False), "operator_decision": stored.get("operator_decision"), "operator_reason": stored.get("operator_reason"), "human_action": stored.get("human_action"), "operator_parent_id": stored.get("operator_parent_id"), "blocking_children": stored.get("blocking_children"), "blocked_by": stored.get("blocked_by"), "wave": stored.get("wave"), "recommended_lane": stored.get("recommended_lane"), "recommended_profile": stored.get("recommended_profile"), "lane": stored.get("lane"), "runtime": stored.get("runtime"), "model": stored.get("model"), "reasoning_effort": stored.get("reasoning_effort"), "synthesis_status": stored.get("synthesis_status"), "write_scope": stored.get("write_scope"), "acceptance_criteria": stored.get("acceptance_criteria"), "metrics_summary": metrics.summary(stored), "pipeline_run_id": stored.get("pipeline_run_id"), "auto_retry_attempts": stored.get("auto_retry_attempts", 0), "escalation_attempts": stored.get("escalation_attempts", []), "iteration_limit": stored.get("iteration_limit"), "available_actions": available_actions(card_id, "intent", stored, job_active), "fixer_attempts": stored.get("fixer_attempts", 0), "fixer_question": stored.get("fixer_question"), "fixer_qa": stored.get("fixer_qa", []), "fixer_target_phase": stored.get("fixer_target_phase"), } ) continue if stored.get("source") == "draft": board.append( { "id": card_id, "source": "draft", "title": stored.get("title", card_id), "phase": stored.get("phase", "entwurf_laeuft"), "updated_at": stored.get("updated_at"), "last_run_status": stored.get("last_run_status"), "draft_conversation": stored.get("draft_conversation", []), "failure_detail": stored.get("failure_detail"), "available_actions": [], } ) continue if stored.get("source") != "idea": continue board.append( { "id": card_id, "source": "idea", "title": stored.get("title", card_id), "phase": stored.get("phase", "idea"), "updated_at": stored.get("updated_at"), "available_actions": available_actions(card_id, "idea", stored, job_active), "fixer_attempts": stored.get("fixer_attempts", 0), "fixer_question": stored.get("fixer_question"), "fixer_qa": stored.get("fixer_qa", []), "fixer_target_phase": stored.get("fixer_target_phase"), } ) active_slugs = orchestration.active_slugs() for card in board: stored = state["cards"].get(card["id"], {}) for key in ("inspector_result", "repo_status", "repo_evidence", "deployment_evidence", "deployment_status", "deployed_commit_hash", "activated_system", "draft_conversation", "draft_origin", "attempt_history", "review_history", "clarification_qa", "clarification_required_action", "deployment_error", "preserved_worktree"): card[key] = stored.get(key) for card in board: stored = state["cards"].get(card["id"], {}) active = card["id"] in active_slugs if card["source"] != "draft": card["available_actions"] = available_actions(card["id"], card["source"], {**stored, "phase": card["phase"]}, active) children = [child for child in board if child.get("parent_id") == card["id"]] card["display"] = workflow.presentation({**stored, **card}, children, active) return board SMOKE_TEST_DIRECT_TEMPLATE = """\ # Intent: Operator Smoke-Test {n} (klein, direct_builder erwartet) ## Ziel Installiere `ripgrep` systemweit auf dem NixOS-Host `blunix`. Dies ist ein kleiner, präziser Auftrag, den der Operator als `direct_builder` einstufen und automatisch in die empfohlene Builder-Pipeline schicken soll. Diese Karte auf „Bereit für Builder" anlegen und dann in die Operator-Spalte ziehen (`operator_angefordert`). ## Auftrag an den Builder - Ergänze ausschließlich `ripgrep` in der bestehenden `environment.systemPackages`-Liste in `hosts/blunix/localServices/packages.nix`. - Sortiere den neuen Eintrag alphabetisch in die Liste ein. - Ändere keine andere Datei und nimm keine weiteren Paket- oder Konfigurationsänderungen vor. ## Erwartetes Ergebnis Operator liefert `direct_builder`, ein Builder-Lauf erzeugt genau einen Commit, der ausschließlich `ripgrep` zu den systemweiten Paketen von `blunix` hinzufügt, danach normaler Review-Übergang. Dieser Intent ist ausschließlich ein technischer Pipeline-Test. """ SMOKE_TEST_SPLIT_TEMPLATE = """\ # Intent: Operator Smoke-Test {n} (groß, split erwartet) ## Ziel Installiere `ripgrep` und `fd` systemweit auf dem NixOS-Host `blunix`. Der Operator soll diesen absichtlich aus zwei unabhängigen Paketänderungen bestehenden Auftrag als `split` einstufen, exakt zwei Kinder automatisch starten und anschließend die Synthese durchlaufen lassen. Diese Karte auf „Bereit für Builder" anlegen und dann in die Operator-Spalte ziehen (`operator_angefordert`). ## Auftrag (an den Operator, zur Zerlegung in Builder-Kinder) Zerlege den Parent in exakt zwei vollständige Builder-Kinder mit unterschiedlichen, um `-{n}` ergänzten Slugs. Beide Kinder dürfen in derselben Welle (`W1`) laufen, da ihre Schreibbereiche nicht überlappen: 1. Das erste Kind ergänzt ausschließlich `ripgrep`, alphabetisch einsortiert, in der bestehenden `environment.systemPackages`-Liste in `hosts/blunix/localServices/packages.nix`. Sein `write_scope` enthält genau diesen Pfad. 2. Das zweite Kind ergänzt ausschließlich `fd`, alphabetisch einsortiert, in der bestehenden `environment.systemPackages`-Liste in `hosts/blunix/localServices/packages-stable.nix`. Sein `write_scope` enthält genau diesen Pfad. Jedes Kind ändert nur seine genannte Datei und nimmt keine weiteren Paket- oder Konfigurationsänderungen vor. Zwischen den Kindern besteht keine Abhängigkeit (`blocked_by` jeweils leer). ## Erwartetes Ergebnis Operator liefert `split` mit exakt zwei Kindern. Beide starten automatisch; nach erfolgreichem Review beider Kinder erzeugt das Board die Synthese, die selbst automatisch durchläuft. Das resultierende System installiert sowohl `ripgrep` als auch `fd` systemweit auf `blunix`. Dieser Intent ist ausschließlich ein technischer Pipeline-Test. """ SMOKE_TEST_REMOVE_DIRECT_TEMPLATE = """\ # Intent: Operator Deinstallations-Smoke-Test {n} (klein, direct_builder erwartet) ## Ziel Deinstalliere `ripgrep` systemweit vom NixOS-Host `blunix`. Dies ist das genaue Gegenstück zum kleinen Installations-Smoke-Test und soll vom Operator als `direct_builder` eingestuft und automatisch in die empfohlene Builder-Pipeline geschickt werden. Diese Karte auf „Bereit für Builder" anlegen und dann in die Operator-Spalte ziehen (`operator_angefordert`). ## Auftrag an den Builder - Entferne ausschließlich `ripgrep` aus der bestehenden `environment.systemPackages`-Liste in `hosts/blunix/localServices/packages.nix`. - Ändere keine andere Datei und nimm keine weiteren Paket- oder Konfigurationsänderungen vor. ## Erwartetes Ergebnis Operator liefert `direct_builder`, ein Builder-Lauf erzeugt genau einen Commit, der ausschließlich `ripgrep` aus den systemweiten Paketen von `blunix` entfernt, danach normaler Review-Übergang. Dieser Intent ist ausschließlich ein technischer Pipeline-Test. """ SMOKE_TEST_REMOVE_SPLIT_TEMPLATE = """\ # Intent: Operator Deinstallations-Smoke-Test {n} (groß, split erwartet) ## Ziel Deinstalliere `ripgrep` und `fd` systemweit vom NixOS-Host `blunix`. Dies ist das genaue Gegenstück zum großen Installations-Smoke-Test. Der Operator soll den Auftrag als `split` einstufen, exakt zwei Kinder automatisch starten und anschließend die Synthese durchlaufen lassen. Diese Karte auf „Bereit für Builder" anlegen und dann in die Operator-Spalte ziehen (`operator_angefordert`). ## Auftrag (an den Operator, zur Zerlegung in Builder-Kinder) Zerlege den Parent in exakt zwei vollständige Builder-Kinder mit unterschiedlichen, um `-{n}` ergänzten Slugs. Beide Kinder dürfen in derselben Welle (`W1`) laufen, da ihre Schreibbereiche nicht überlappen: 1. Das erste Kind entfernt ausschließlich `ripgrep` aus der bestehenden `environment.systemPackages`-Liste in `hosts/blunix/localServices/packages.nix`. Sein `write_scope` enthält genau diesen Pfad. 2. Das zweite Kind entfernt ausschließlich `fd` aus der bestehenden `environment.systemPackages`-Liste in `hosts/blunix/localServices/packages-stable.nix`. Sein `write_scope` enthält genau diesen Pfad. Jedes Kind ändert nur seine genannte Datei und nimmt keine weiteren Paket- oder Konfigurationsänderungen vor. Zwischen den Kindern besteht keine Abhängigkeit (`blocked_by` jeweils leer). ## Erwartetes Ergebnis Operator liefert `split` mit exakt zwei Kindern. Beide starten automatisch; nach erfolgreichem Review beider Kinder erzeugt das Board die Synthese, die selbst automatisch durchläuft. Das resultierende System installiert weder `ripgrep` noch `fd` systemweit auf `blunix`. Dieser Intent ist ausschließlich ein technischer Pipeline-Test. """ SMOKE_TEST_KINDS = { "direct": ("board-operator-smoke-test-direct", SMOKE_TEST_DIRECT_TEMPLATE), "split": ("board-operator-smoke-test-split", SMOKE_TEST_SPLIT_TEMPLATE), "remove-direct": ( "board-operator-smoke-test-remove-direct", SMOKE_TEST_REMOVE_DIRECT_TEMPLATE, ), "remove-split": ( "board-operator-smoke-test-remove-split", SMOKE_TEST_REMOVE_SPLIT_TEMPLATE, ), } def create_smoke_test(kind): """Write a fresh, numbered install or removal smoke-test intent. The board exposes direct_builder and split variants of both operations. Separate prefixes prevent collisions between kinds; for split tests, the Operator derives fresh child slugs from the numbered intent text. """ if kind not in SMOKE_TEST_KINDS: raise ValueError("unbekannte Smoke-Test-Art") prefix, template = SMOKE_TEST_KINDS[kind] intent_dir = Path(os.environ["BOARD_INTENT_DIR"]) intent_dir.mkdir(parents=True, exist_ok=True) existing = {int(m.group(1)) for name in intent_dir.glob(f"{prefix}-*.md") if (m := re.fullmatch(rf"{prefix}-(\d+)\.md", name.name))} n = max(existing, default=0) + 1 slug = f"{prefix}-{n}" (intent_dir / f"{slug}.md").write_text(template.format(n=n), encoding="utf-8") return slug SMOKE_TEST_MARKER_RE = re.compile(r"smoke", re.IGNORECASE) def _smoke_test_directories(): repo_root = Path(os.environ["BOARD_REPO_DIR"]) return { "docs/intent": Path(os.environ["BOARD_INTENT_DIR"]), "docs/research-intent": Path(os.environ["BOARD_RESEARCH_INTENT_DIR"]), "docs/review": repo_root / "docs" / "review", "docs/research": repo_root / "docs" / "research", } def _expand_card_family(seeds, cards): """Follow parent/child/synthesis links outward from seeds until the selection stops growing -- a smoke test's split children, fan-out children and generated synthesis card never carry "smoke" in their own slug or title, so a name match alone would leave them behind. """ selected = set(seeds) changed = True while changed: changed = False for card_id, card in cards.items(): refs = set(card.get("depends_on") or []) | set(card.get("child_slugs") or []) refs.update( ref for ref in ( card.get("parent_id"), card.get("operator_parent_id"), card.get("synthesis_for"), ) if ref ) if card_id in selected or refs & selected: before = len(selected) selected.add(card_id) selected.update(ref for ref in refs if ref in cards) if len(selected) > before: changed = True return selected def delete_smoke_test_cards(): """Remove every smoke-test card, and its whole linked family (split children, fan-out children, a generated synthesis card), from the board at once. This is the same cleanup an operator previously had to script by hand after a round of pipeline smoke testing, now reachable from the board itself. Refuses while any board job is queued or running -- the same repo_job_active() guard research transitions already use -- so this can never delete an intent file out from under a job that is still reading it. Backing docs/intent, docs/research-intent, docs/review and docs/research files are deleted outright: these are throwaway pipeline-test artifacts, not files anyone edits by hand, and the calling UI confirms before sending this request. """ if repo_job_active(): raise PermissionError("Ein Board-Job läuft oder ist eingereiht") directories = _smoke_test_directories() with STATE_LOCK: state = load_state() cards = state["cards"] seeds = { card_id for card_id, card in cards.items() if SMOKE_TEST_MARKER_RE.search(f"{card_id} {card.get('title', '')}") } for directory in directories.values(): if directory.is_dir(): seeds.update( path.stem for path in directory.glob("*.md") if SMOKE_TEST_MARKER_RE.search(path.stem) ) selected = _expand_card_family(seeds, cards) removed_files = 0 for directory in directories.values(): if not directory.is_dir(): continue directory_resolved = directory.resolve(strict=True) for slug in selected: if not SLUG_RE.fullmatch(slug): continue candidate = directory / f"{slug}.md" if not candidate.is_file(): continue resolved = candidate.resolve(strict=True) try: resolved.relative_to(directory_resolved) except ValueError: continue if resolved != directory_resolved / f"{slug}.md": continue try: resolved.unlink() removed_files += 1 except OSError as error: # A single unwritable file (e.g. a sandbox directory # missing from this service's ReadWritePaths) must not # take the whole request down -- verified live # 2026-09-10: an uncaught OSError here killed the # connection mid-response, which Traefik then surfaced # as a 502. State/telemetry cleanup below still runs # for every selected slug regardless. print(f"Smoke-Test-Datei konnte nicht entfernt werden ({resolved}): {error}", file=sys.stderr) removed_cards = sum(1 for slug in selected if cards.pop(slug, None) is not None) save_state(state) try: telemetry.delete_slugs(Path(os.environ["BOARD_TELEMETRY_DB"]), selected) except (OSError, ValueError) as error: print(f"Telemetrie-Bereinigung nach Smoke-Test-Löschung fehlgeschlagen: {error}", file=sys.stderr) return {"removed_cards": removed_cards, "removed_files": removed_files} def create_idea(title): title = title.strip() if not title or len(title) > 200: raise ValueError("invalid title") card_id = "idea-" + uuid.uuid4().hex[:12] with STATE_LOCK: state = load_state() state["cards"][card_id] = { "source": "idea", "title": title, "phase": "idea", "updated_at": now_iso(), } save_state(state) return card_id def delete_manual_card(card_id): """Delete a card that lives only in state.json -- a manual "idea" backlog card, or an abandoned interactive-intake "draft" that never finished. A *finished* draft is no longer a "draft" source by the time this could run: record_draft_intent() replaces it with a normal "geplant" intent card, so this path never touches it.""" with STATE_LOCK: state = load_state() card = state["cards"].get(card_id) if not card or card.get("source") not in ("idea", "draft"): raise LookupError("not a manually created card") del state["cards"][card_id] save_state(state) MAX_DRAFT_MESSAGE_LENGTH = 4000 MAX_DRAFT_CONVERSATION_TURNS = 40 def _queue_draft_job(state, card_id, conversation): """Write a job_type="draft" job and update the card's own state entry in place. Assumes the caller already holds STATE_LOCK and has already called load_state() into `state` -- same pure state-mutation-helper pattern as _queue_clarification() above. Shared by create_draft() (the opening turn), answer_draft() and retry_draft() (a follow-up turn) below. """ stored = state["cards"].setdefault(card_id, {}) job_id = write_job( card_id, stored.get("title", card_id), "", job_type="draft", conversation=conversation, ) stored.update( source="draft", phase="entwurf_laeuft", last_job_id=job_id, last_run_status="queued_draft", draft_conversation=conversation, updated_at=now_iso(), ) return job_id def create_draft(topic): """Start a new interactive Planner intake dialogue: a draft card with no backing file yet, its opening turn queued immediately. Deliberately no repo_job_active() guard, unlike research/fixer transitions: an ordinary draft turn never touches the shared checkout (see worker.py's run_draft()), so it can run alongside a Builder/ Review/Research/Split job without contending for anything. Only the single narrow moment where a finished dialogue writes its new intent file takes the repo lock -- see worker.py's job_type == "draft" handling. """ topic = str(topic or "").strip() if not topic or len(topic) > 200: raise ValueError("ein Thema mit maximal 200 Zeichen ist erforderlich") card_id = "draft-" + uuid.uuid4().hex[:12] conversation = [{"role": "user", "text": topic}] with STATE_LOCK: state = load_state() state["cards"][card_id] = {"title": topic} _queue_draft_job(state, card_id, conversation) save_state(state) return card_id def plan_card(card_id): """Refine an idea in place, preserving its identity.""" with STATE_LOCK: state = load_state() stored = state["cards"].get(card_id, {}) if stored.get("source") != "idea" or stored.get("phase") == "archiviert": raise PermissionError("Only active ideas can start this planning conversation.") _queue_draft_job(state, card_id, [{"role": "user", "text": stored["title"]}]) save_state(state) return card_id def retry_deployment(card_id): with STATE_LOCK: state = load_state() card = state["cards"].get(card_id, {}) if (card.get("phase") == "archiviert" or card.get("failure_stage") != "deployment" or card.get("review_decision") != "bereit_fuer_deployment" or not card.get("commit_hash") or card_id in orchestration.active_slugs()): raise PermissionError("Only an inactive, failed deployment of an approved commit can be retried.") job_id = write_job(card_id, card.get("title", card_id), card.get("intent_path", ""), job_type="retry_deployment", commit_hash=card["commit_hash"], pipeline_run_id=card.get("pipeline_run_id")) card.update(last_job_id=job_id, last_run_status="queued", updated_at=now_iso()) save_state(state) return job_id def answer_draft(card_id, answer): """Answer the Planner's latest draft question and queue the next turn.""" with STATE_LOCK: state = load_state() stored = state["cards"].get(card_id) if not stored or stored.get("source") != "draft": raise LookupError("unknown draft card") if stored.get("phase") != "entwurf_frage": raise PermissionError("Antwort ist nur bei einer offenen Rückfrage möglich") answer = str(answer or "").strip() if not answer or len(answer) > MAX_DRAFT_MESSAGE_LENGTH: raise ValueError(f"eine Antwort mit maximal {MAX_DRAFT_MESSAGE_LENGTH} Zeichen ist erforderlich") conversation = [*(stored.get("draft_conversation") or []), {"role": "user", "text": answer}] if len(conversation) > MAX_DRAFT_CONVERSATION_TURNS: raise PermissionError( "Konversation ist zu lang -- als Intent per Hand anlegen oder neu beginnen" ) job_id = _queue_draft_job(state, card_id, conversation) save_state(state) return job_id def retry_draft(card_id): """Re-run the last Planner turn unchanged -- for a draft whose last turn failed (a runtime error, a malformed response, a slug collision at finalization), since a retry needs no new operator input.""" with STATE_LOCK: state = load_state() stored = state["cards"].get(card_id) if not stored or stored.get("source") != "draft": raise LookupError("unknown draft card") if stored.get("phase") != "entwurf_fehlgeschlagen": raise PermissionError("Erneuter Versuch ist nur nach einem fehlgeschlagenen Lauf möglich") conversation = stored.get("draft_conversation") or [] job_id = _queue_draft_job(state, card_id, conversation) save_state(state) return job_id def read_review_report(slug): """Read only the fixed report belonging to an existing intent card. The request contributes a validated slug, never a filesystem path. The state entry must contain the exact path the worker creates, and resolving that path must keep it inside this repository's docs/review directory. """ if not SLUG_RE.fullmatch(slug) or slug not in list_intent_cards(): raise LookupError("unknown intent card") expected_path = f"docs/review/{slug}.md" with STATE_LOCK: stored = load_state()["cards"].get(slug, {}) if stored.get("review_report") != expected_path: raise LookupError("review report not found") repo_root = Path(os.environ["BOARD_REPO_DIR"]).resolve(strict=True) review_root = (repo_root / "docs" / "review").resolve(strict=True) report_path = repo_root / expected_path resolved_report = report_path.resolve(strict=True) try: review_root.relative_to(repo_root) resolved_report.relative_to(review_root) except ValueError as error: raise LookupError("invalid review report path") from error if resolved_report != review_root / f"{slug}.md": raise LookupError("invalid review report path") return {"path": expected_path, "content": resolved_report.read_text(encoding="utf-8")} def read_research_report(slug): """Read only the fixed research report belonging to a research card.""" if not SLUG_RE.fullmatch(slug) or slug not in list_research_cards(): raise LookupError("unknown research card") expected_path = f"docs/research/{slug}.md" with STATE_LOCK: stored = load_state()["cards"].get(slug, {}) if stored.get("research_report") != expected_path: raise LookupError("research report not found") repo_root = Path(os.environ["BOARD_REPO_DIR"]).resolve(strict=True) research_root = (repo_root / "docs" / "research").resolve(strict=True) resolved_report = (repo_root / expected_path).resolve(strict=True) try: research_root.relative_to(repo_root) resolved_report.relative_to(research_root) except ValueError as error: raise LookupError("invalid research report path") from error if resolved_report != research_root / f"{slug}.md": raise LookupError("invalid research report path") return {"path": expected_path, "content": resolved_report.read_text(encoding="utf-8")} def read_research_intent(slug): """Read the validated source document belonging to a research card.""" if not SLUG_RE.fullmatch(slug) or slug not in list_research_cards(): raise LookupError("unknown research card") expected_path = f"docs/research-intent/{slug}.md" repo_root = Path(os.environ["BOARD_REPO_DIR"]).resolve(strict=True) intent_root = (repo_root / "docs" / "research-intent").resolve(strict=True) resolved_intent = (repo_root / expected_path).resolve(strict=True) try: intent_root.relative_to(repo_root) resolved_intent.relative_to(intent_root) except ValueError as error: raise LookupError("invalid research intent path") from error if resolved_intent != intent_root / f"{slug}.md": raise LookupError("invalid research intent path") return {"path": expected_path, "content": resolved_intent.read_text(encoding="utf-8")} def read_intent(slug): """Read the intent document belonging to an existing intent card.""" if not SLUG_RE.fullmatch(slug) or slug not in list_intent_cards(): raise LookupError("unknown intent card") expected_path = f"docs/intent/{slug}.md" repo_root = Path(os.environ["BOARD_REPO_DIR"]).resolve(strict=True) intent_root = (repo_root / "docs" / "intent").resolve(strict=True) intent_path = repo_root / expected_path resolved_intent = intent_path.resolve(strict=True) try: resolved_intent.relative_to(intent_root) except ValueError as error: raise LookupError("invalid intent path") from error if resolved_intent != intent_root / f"{slug}.md": raise LookupError("invalid intent path") return {"path": expected_path, "content": resolved_intent.read_text(encoding="utf-8")} # Kept in sync by hand with the role each job-triggering phase actually # runs (worker.py's *_PROMPT_TEMPLATEs and agent-codex/builder.py's own # fixer/builder prompts all name their role file explicitly, e.g. # "Du bist die durch reviewer.md definierte Reviewer-Rolle") -- this is # purely a read-only "view the role prompt" link for the Agents column, # not itself involved in dispatching a job. AGENT_ROLE_FILES = { "operator": "operator.md", "builder": "builder.md", "reviewer": "reviewer.md", "researcher": "researcher.md", "planner": "planner.md", "inspector": "inspector.md", "fixer": "fixer.md", } def read_agent_prompt(role): """Read a fixed agent role-prompt file from BOARD_ROLE_DIR (agents/). Same validated-allowlist pattern as read_intent() above, just rooted at the role directory instead of docs/intent: the request contributes a role name checked against AGENT_ROLE_FILES, never a filesystem path. """ filename = AGENT_ROLE_FILES.get(role) if not filename: raise LookupError("unknown agent role") role_root = Path(os.environ["BOARD_ROLE_DIR"]).resolve(strict=True) prompt_path = (role_root / filename).resolve(strict=True) try: prompt_path.relative_to(role_root) except ValueError as error: raise LookupError("invalid role prompt path") from error if prompt_path != role_root / filename: raise LookupError("invalid role prompt path") return {"path": f"agents/{filename}", "content": prompt_path.read_text(encoding="utf-8")} def read_card_telemetry(slug): if not SLUG_RE.fullmatch(slug): raise LookupError("unknown card") stored = load_state().get("cards", {}).get(slug, {}) if slug not in list_intent_cards() and slug not in list_research_cards() and not stored: raise LookupError("unknown card") pipeline_run_id = stored.get("pipeline_run_id") current = ( telemetry.get_pipeline_run(Path(os.environ["BOARD_TELEMETRY_DB"]), pipeline_run_id) if pipeline_run_id else None ) return { "slug": slug, "pipeline_run_id": pipeline_run_id, "current_pipeline": current, "runs": telemetry.get_slug_runs( Path(os.environ["BOARD_TELEMETRY_DB"]), slug, limit=20 ), } def repo_job_active(): """True if a job is queued or currently being run by worker.py -- the job file sits in BOARD_PROCESSING_DIR for the whole `codex exec` duration, not just while queued, so this covers both "waiting" and "mid-run". Used as the trigger-side guard for research jobs, which (per docs/intent/board-builder-worktree-parallelism.md) still run against the one shared checkout and therefore still need the full, untargeted lock against *any* other job -- builder jobs use the slug-aware, concurrency-limited builder_trigger_blocked() below instead. """ for directory_env in ("BOARD_INBOX_DIR", "BOARD_PROCESSING_DIR", "AGENT_BUILDER_QUEUE_DIR", "AGENT_BUILDER_PROCESSING_DIR", "AGENT_BUILDER_QUEUE_DIR_LEICHT", "AGENT_BUILDER_PROCESSING_DIR_LEICHT"): if not os.environ.get(directory_env): continue directory = Path(os.environ[directory_env]) if directory.is_dir() and any(directory.glob("*.json")): return True return False def queued_jobs(): """Parse every job currently queued or in flight (BOARD_INBOX_DIR and BOARD_PROCESSING_DIR) into a list of dicts. Corrupt or unreadable entries are skipped rather than raising -- a bad file here must never block the trigger API.""" jobs = [] for directory_env in ("BOARD_INBOX_DIR", "BOARD_PROCESSING_DIR", "AGENT_BUILDER_QUEUE_DIR", "AGENT_BUILDER_PROCESSING_DIR", "AGENT_BUILDER_QUEUE_DIR_LEICHT", "AGENT_BUILDER_PROCESSING_DIR_LEICHT"): if not os.environ.get(directory_env): continue directory = Path(os.environ[directory_env]) if not directory.is_dir(): continue for job_path in sorted(directory.glob("*.json")): try: job = json.loads(job_path.read_text(encoding="utf-8")) job["slug"] = job.get("slug") or job.get("board_slug") if directory_env.startswith("AGENT_BUILDER"): job.setdefault("job_type", "builder") job.setdefault("lane", "leicht" if directory_env.endswith("LEICHT") else "schwer") jobs.append(job) except (OSError, ValueError): continue return jobs def builder_trigger_blocked(card_id, lane="schwer"): """Returns an error string if a new builder job for card_id must not be queued yet, else None. Builder jobs alone may be queued concurrently for *different* cards -- see docs/intent/board-builder-worktree-parallelism.md for the isolated per-job git worktrees (agent-codex/builder.py's create_worktree()/ remove_worktree(), only for the flake repo) that make that safe. Since docs/intent/board-builder-weighted-lanes.md gave each of the two Builder columns its own dedicated agent-codex-builder-1/-2 instance and inbox, the cap is enforced per lane (exactly one in-flight job per lane, matching one dedicated instance each) rather than as one shared number -- a "schwer" and a "leicht" job may always run at the same time, but two "schwer" (or two "leicht") jobs may not. Research/review jobs still use the one shared checkout without any lock of their own on the trigger side, so any of those queued or running still blocks a new builder job entirely, same as before.""" jobs = queued_jobs() if any(job.get("job_type", "builder") != "builder" for job in jobs): return "a board-agent job is already queued or running against this repository" builder_jobs = [job for job in jobs if job.get("job_type", "builder") == "builder"] if any(job.get("slug") == card_id for job in builder_jobs): return "a board-agent job is already queued or running for this card" if any(job.get("lane", "schwer") == lane for job in builder_jobs): return f"a board-agent builder job is already queued or running for lane '{lane}'" return None def write_job(slug, title, intent_path, job_type="builder", runtime=None, **extra): job_id = f"{slug}-{uuid.uuid4().hex[:16]}" inbox = Path(os.environ["BOARD_INBOX_DIR"]) inbox.mkdir(parents=True, exist_ok=True, mode=0o700) pipeline_run_id = extra.pop("pipeline_run_id", None) if not pipeline_run_id and os.environ.get("BOARD_STATE_FILE"): stored = load_state().get("cards", {}).get(slug, {}) pipeline_run_id = stored.get("pipeline_run_id") parent_slug = stored.get("operator_parent_id") or stored.get("synthesis_for") if not pipeline_run_id and parent_slug: pipeline_run_id = ( load_state().get("cards", {}).get(parent_slug, {}).get("pipeline_run_id") ) pipeline_run_id = pipeline_run_id or uuid.uuid4().hex job = { "job_id": job_id, "job_type": job_type, "slug": slug, "title": title, "intent_path": intent_path, "requested_at": now_iso(), "runtime": validate_runtime(runtime or board_runtime()), "pipeline_run_id": pipeline_run_id, **extra, } state_store.write_payload(inbox / f"{job_id}.json", job) return job_id def builder_column_payload(column="in_umsetzung"): """Snapshot the Builder column settings for a queued job. The values are read once, at trigger time, so changing the column later cannot alter a job that is already waiting in the inbox. Empty optional values are omitted because the worker uses its own defaults in that case. `column` selects which of the two Builder columns (docs/intent/board-builder-weighted-lanes.md) to snapshot; defaults to "in_umsetzung" ("schwer") for the split-escalation call site, which is deliberately not lane-aware. """ config = load_column_config()[column] extra = { key: config[key] for key in ("model", "reasoning_effort") if config.get(key) } extra["iteration_limit"] = config["iteration_limit"] return config["runtime"], extra def known_card_ids(): """Every slug the board currently recognizes as a real card -- intent, research, or manually created idea -- used to validate both ends of a link_parent() call before touching fanout.py's state, which otherwise trusts its caller to have already done this (see fanout.link_child()'s docstring).""" ids = set(list_intent_cards()) | set(list_research_cards()) for card_id, stored in load_state().get("cards", {}).items(): if stored.get("source") == "idea": ids.add(card_id) return ids def link_parent(child_id, parent_id): """Validate both slugs are real, known cards, then delegate the actual bookkeeping write to fanout.link_child() -- see docs/intent/board-subintent-fanout-synthesis.md.""" with STATE_LOCK: ids = known_card_ids() if child_id not in ids: raise LookupError("unknown card") if parent_id not in ids: raise LookupError("unknown parent card") fanout.link_child(parent_id, child_id) def retry_review(card_id, commit_hash=None): """Queue a fresh Review job against a card's own already-recorded commit_hash, without a new Builder run. Exists because a Review job can fail for reasons that have nothing to do with the commit itself (found live 2026-09-09, operator-smoke-split-docs and operator-smoke-split-agent-codex-2: two independent Reviewer phrasing variants run_review()'s decision match didn't tolerate, since fixed) -- rebuilding via the normal "geplant -> in_umsetzung" path is the wrong recovery in that case, since Builder correctly reports "no_changes" when its own acceptance criteria are already met by the existing commit, and no_changes never queues a new Review. This re-runs exactly the Review step completion_watcher.py would have queued after that original commit. An explicit commit_hash overrides the card's own stored one -- needed only for a card whose Review failed *before* completion_watcher.py's 2026-09-09 fix that records commit_hash at the commit itself rather than only on a successful Review; every card going forward has its own stored value and never needs the override. Validated against the same hex-digest shape agent-codex/builder.py's COMMIT_RECORD_RE extracts, since it flows straight into run_review()'s `git show`/ `git diff` calls. Reuses the runtime/model/reasoning_effort already stored on the card (i.e. whatever the original Builder/Operator run actually used) rather than the live "review" column config, so a retry reviews the same commit under the same profile that produced it, not whatever config happens to be set right now. """ if not SLUG_RE.fullmatch(card_id): raise LookupError("unknown card") if commit_hash is not None and not re.fullmatch(r"[0-9a-f]{7,40}", commit_hash): raise ValueError("ungültiger commit_hash") with STATE_LOCK: intent_cards = list_intent_cards() if card_id not in intent_cards: raise LookupError("unknown intent card") state = load_state() stored = state["cards"].get(card_id, {}) commit_hash = commit_hash or stored.get("commit_hash") if not commit_hash: raise LookupError("card has no recorded commit to review") if repo_job_active(): raise PermissionError( "a board-agent job is already queued or running against this repository" ) runtime = validate_runtime(stored.get("runtime") or board_runtime()) extra = { key: stored[key] for key in ("model", "reasoning_effort") if stored.get(key) } job_id = write_job( card_id, intent_cards[card_id]["title"], intent_cards[card_id]["intent_path"], job_type="review", runtime=runtime, commit_hash=commit_hash, pipeline_run_id=stored.get("pipeline_run_id") or uuid.uuid4().hex, **extra, ) stored.update( { "source": "intent", "phase": "review", "last_job_id": job_id, "last_run_status": "queued_review", "updated_at": now_iso(), } ) state["cards"][card_id] = stored save_state(state) return job_id def _migrate_clarification_state(stored): """Read old Fixer cards without rewriting state merely on GET.""" migrated = dict(stored) if "clarification_attempts" not in migrated and "fixer_attempts" in migrated: migrated["clarification_attempts"] = migrated.get("fixer_attempts", 0) if "clarification_question" not in migrated and "fixer_question" in migrated: migrated["clarification_question"] = migrated.get("fixer_question") if "clarification_qa" not in migrated and "fixer_qa" in migrated: migrated["clarification_qa"] = migrated.get("fixer_qa", []) return migrated def _queue_clarification(card_id, stored, card, task_type, answer=None): if task_type not in CLARIFICATION_TASK_TYPES: raise ValueError("Auftragstyp muss implementation_status, route_next oder repair sein") stored.update(_migrate_clarification_state(stored)) attempts = int(stored.get("fixer_attempts", 0)) if task_type == "repair" and answer is None: if not repair_eligible(stored): raise PermissionError("repair ist nur für echte Fehler oder negative Reviews erlaubt") if task_type == "repair" and attempts >= 2: raise PermissionError("maximal zwei Fixer-Versuche pro Karte") qa = stored.get("clarification_qa") or stored.get("fixer_qa") or [] if answer is not None: question = str(stored.get("clarification_question") or stored.get("fixer_question") or "").strip() answer = str(answer or "").strip() if not question or not answer or len(answer) > 4000: raise ValueError("eine konkrete Antwort mit maximal 4000 Zeichen ist erforderlich") qa = [*qa, {"question": question, "answer": answer}] config = load_column_config()["klaerung_reparatur"] pipeline_run_id = stored.get("pipeline_run_id") or uuid.uuid4().hex job_id = write_job( card_id, card["title"], card["intent_path"], job_type="repair" if task_type == "repair" else "inspector", runtime=config["runtime"], clarification_task_type=task_type, pipeline_run_id=pipeline_run_id, model=config.get("model") or None, reasoning_effort=config.get("reasoning_effort") or None, iteration_limit=config["iteration_limit"], builder_report=stored.get("builder_report", ""), failure_stage=stored.get("failure_stage", ""), failure_detail=stored.get("failure_detail", ""), review_report=stored.get("review_report", ""), commit_hash=stored.get("commit_hash", ""), original_phase=stored.get("clarification_origin_phase") or stored.get("phase", ""), fixer_qa=qa, fixer_attempts=attempts + (1 if task_type == "repair" else 0), deployment_evidence=deployment_evidence(stored), checked_revision=stored.get("commit_hash") or None, ) stored.update( phase="klaerung_reparatur", last_job_id=job_id, last_run_status="queued_repair" if task_type == "repair" else "queued_inspector", clarification_task_type=task_type, clarification_origin_phase=stored.get("clarification_origin_phase") or stored.get("phase", ""), fixer_attempts=attempts + (1 if task_type == "repair" else 0), clarification_attempts=attempts + (1 if task_type == "repair" else 0), fixer_qa=qa, clarification_qa=qa, fixer_question=None, clarification_question=None, pipeline_run_id=pipeline_run_id, runtime=config["runtime"], model=config.get("model", ""), reasoning_effort=config.get("reasoning_effort", ""), iteration_limit=config["iteration_limit"], updated_at=now_iso(), ) return job_id def deployment_evidence(stored): status = stored.get("deployment_status") if workflow.verified_deployment(stored): normalized = "deployed" elif status in {"deploy_failed", "not_deployed"}: normalized = "not_deployed" else: normalized = "unknown" return { "status": normalized, "commit_hash": stored.get("commit_hash", ""), "deploy_job_id": stored.get("deploy_job_id", ""), "source": "trusted board wrapper state", } def answer_fixer(card_id, answer): with STATE_LOCK: cards = {**list_intent_cards(), **list_research_cards()} state = load_state() stored = state["cards"].get(card_id, {}) if card_id not in cards and stored.get("source") != "idea": raise LookupError("unknown intent card") if stored.get("phase") not in {"fixer_rueckfrage", "inspector_rueckfrage"}: raise PermissionError("Antwort ist nur bei einer persistenten Rückfrage erlaubt") if repo_job_active(): raise PermissionError("a board-agent job is already queued or running") card = cards.get(card_id) or { "id": card_id, "source": "idea", "title": stored.get("title", card_id), "intent_path": "", } task_type = stored.get("clarification_task_type") or "repair" job_id = _queue_clarification(card_id, stored, card, task_type, answer=answer) state["cards"][card_id] = stored save_state(state) return job_id def apply_routing_recommendation(card_id): """Apply only an Inspector recommendation explicitly confirmed by the user.""" with STATE_LOCK: state = load_state() stored = state.get("cards", {}).get(card_id, {}) action = stored.get("routing_recommendation") if not stored.get("routing_pending") or action not in ROUTING_ACTION_PHASES: raise PermissionError("keine bestätigbare Routing-Empfehlung vorhanden") if action == "review": retry_review(card_id) elif action in {"in_umsetzung", "in_umsetzung_leicht"} and stored.get("phase") != "geplant": update_phase(card_id, "geplant") update_phase(card_id, action) else: update_phase(card_id, action) state = load_state() stored = state["cards"][card_id] stored.update(routing_pending=False, routing_applied_at=now_iso(), updated_at=now_iso()) state["cards"][card_id] = stored save_state(state) return action def update_phase(card_id, new_phase, task_type=None): if new_phase not in PHASES and new_phase not in RESEARCH_PHASES: raise ValueError("invalid phase") intent_cards = list_intent_cards() research_cards = list_research_cards() with STATE_LOCK: state = load_state() is_intent = card_id in intent_cards is_research = card_id in research_cards stored = state["cards"].get(card_id, {}) if not is_intent and not is_research and stored.get("source") != "idea": raise LookupError("unknown card") current_phase = stored.get("phase", "recherche_angefordert" if is_research else ("geplant" if is_intent else "idea")) job_id = None if new_phase in {"deployed", "bereit_fuer_deployment", "recherche_fertig"}: raise PermissionError("This state requires a verified worker result, not a manual move.") if card_id in orchestration.active_slugs(): raise PermissionError("This card has queued or active work; wait for its result.") if new_phase == "review": return retry_review(card_id) if new_phase == "archiviert": descendants = set(stored.get("child_slugs") or stored.get("depends_on") or []) if descendants & orchestration.active_slugs(): raise PermissionError("Active children must finish before this family can be archived.") stored.update(phase="archiviert", updated_at=now_iso()) state["cards"][card_id] = stored save_state(state) return None reset_phase = "recherche_angefordert" if is_research else "geplant" if is_intent else "idea" if new_phase == reset_phase: if is_research and current_phase not in RESEARCH_PHASES and current_phase not in { "klaerung_reparatur", "inspector_laeuft", "inspector_rueckfrage", "inspector_fehlgeschlagen", }: raise PermissionError("Research card has an invalid stored phase.") history = stored.setdefault("attempt_history", []) if stored.get("last_job_id"): history.append({key: stored.get(key) for key in ("last_job_id", "last_run_status", "last_run_at", "commit_hash", "review_decision", "builder_report", "failure_detail", "pipeline_run_id")}) del history[:-100] for key in ("last_run_status", "failure_stage", "failure_detail", "clarification_origin_phase", "routing_pending", "operator_dispatch_state", "review_decision", "review_report", "auto_retry_attempts", "deployment_status", "deployed_commit_hash", "activated_system", "deploy_job_id", "expected_builder_job_id"): stored.pop(key, None) stored.update(phase=reset_phase, updated_at=now_iso()) state["cards"][card_id] = stored save_state(state) return None fixer_phases = { "fixer_angefordert", "fixer_laeuft", "fixer_rueckfrage", "fixer_fehlgeschlagen", "klaerung_reparatur", "inspector_laeuft", "inspector_rueckfrage", "inspector_fehlgeschlagen", } if new_phase in {"fixer_angefordert", "klaerung_reparatur"}: if repo_job_active(): raise PermissionError("a board-agent job is already queued or running") card = intent_cards.get(card_id) or research_cards.get(card_id) or { "id": card_id, "source": "idea", "title": stored.get("title", card_id), "intent_path": "", } job_id = _queue_clarification( card_id, stored, card, task_type or ("repair" if new_phase == "fixer_angefordert" else "") ) stored["source"] = card["source"] state["cards"][card_id] = stored save_state(state) return job_id if new_phase in fixer_phases: raise PermissionError("diese Fixer-Phase wird nur automatisch gesetzt") if current_phase in fixer_phases: if current_phase == "fixer_rueckfrage": if new_phase == "review": # A human may send a question-blocked card to Review for # inspection without answering or discarding the # persistent Fixer question. stored["phase"] = "review" stored["updated_at"] = now_iso() state["cards"][card_id] = stored save_state(state) return None raise PermissionError("Rückfrage über das Antwortfeld beantworten") if new_phase != "geplant": raise PermissionError("Fixer-Ergebnis muss zuerst nach 'geplant' zurückgesetzt werden") stored["phase"] = "geplant" stored["updated_at"] = now_iso() state["cards"][card_id] = stored save_state(state) return None if is_research: if current_phase not in RESEARCH_PHASES: raise PermissionError("research card has an invalid stored phase") if new_phase == "archiviert" or current_phase == "archiviert": if new_phase == "recherche_laeuft": raise PermissionError("archived cards must move to 'recherche_angefordert' before 'recherche_laeuft'") stored["phase"] = new_phase stored["updated_at"] = now_iso() stored["source"] = "research" state["cards"][card_id] = stored save_state(state) fanout.maybe_synthesize(card_id) return None if new_phase not in RESEARCH_PHASES: raise PermissionError("research cards require research phases") if RESEARCH_PHASE_RANK[new_phase] > RESEARCH_PHASE_RANK[current_phase] and RESEARCH_PHASE_RANK[new_phase] - RESEARCH_PHASE_RANK[current_phase] > 1: raise PermissionError("forward transition skips a research stage") if new_phase == "recherche_laeuft": if current_phase != "recherche_angefordert": raise PermissionError("trigger only allowed from 'recherche_angefordert'") if repo_job_active(): raise PermissionError("a board-agent job is already queued or running against this repository") config = load_column_config()["recherche_laeuft"] extra = { key: config[key] for key in ("model", "reasoning_effort") if config.get(key) } runtime = config["runtime"] pipeline_run_id = uuid.uuid4().hex job_id = write_job(card_id, research_cards[card_id]["title"], research_cards[card_id]["intent_path"], job_type="research", runtime=runtime, research_intent_path=research_cards[card_id]["intent_path"], pipeline_run_id=pipeline_run_id, **extra) stored.update({"source": "research", "last_job_id": job_id, "last_run_status": "queued", "runtime": runtime, "pipeline_run_id": pipeline_run_id}) stored["phase"] = new_phase stored["updated_at"] = now_iso() stored["source"] = "research" state["cards"][card_id] = stored save_state(state) # Covers a card manually dragged straight to recherche_fertig/ # -fehlgeschlagen, not just the automatic worker.py completion # path -- see docs/intent/board-subintent-fanout-synthesis.md. fanout.maybe_synthesize(card_id) return job_id # Archiving is an explicit bookkeeping escape hatch. It is allowed # from every phase and never queues a pipeline job. Reactivation must # go through geplant before the normal builder trigger can be used. if current_phase == "archiviert" and new_phase in ("in_umsetzung", "in_umsetzung_leicht"): raise PermissionError( "archived cards must move to 'geplant' before 'in_umsetzung'" ) if new_phase == "archiviert" or current_phase == "archiviert": stored["phase"] = new_phase stored["updated_at"] = now_iso() if is_intent: stored["source"] = "intent" state["cards"][card_id] = stored save_state(state) fanout.maybe_synthesize(card_id) return None # The Operator request is the only manually reachable Operator # transition. The remaining phases are worker/scheduler outcomes. operator_phases = { "operator_angefordert", "operator_laeuft", "wartet_auf_kinder", "durch_kinder_blockiert", "operator_klaerung_noetig", } if new_phase == "operator_angefordert": if not is_intent: raise PermissionError("nur Intent-Karten können vom Operator analysiert werden") if stored.get("operator_generated"): raise PermissionError("Operator-Kinder dürfen nicht rekursiv analysiert werden") if current_phase != "geplant": raise PermissionError("Operator-Trigger ist nur aus 'geplant' erlaubt") if not SLUG_RE.fullmatch(f"{card_id}-synthese"): raise PermissionError("Karten-Slug ist für einen Synthese-Intent zu lang") if repo_job_active(): raise PermissionError( "a board-agent job is already queued or running against this repository" ) config = load_column_config()["operator_angefordert"] extra = { key: config[key] for key in ("model", "reasoning_effort") if config.get(key) } pipeline_run_id = uuid.uuid4().hex job_id = write_job( card_id, intent_cards[card_id]["title"], intent_cards[card_id]["intent_path"], job_type="operator", runtime=config["runtime"], builder_profiles=orchestration.configured_profiles(load_column_config()), pipeline_run_id=pipeline_run_id, **extra, ) # A card may be sent to the Operator after an earlier Builder or # Review attempt. Those fields describe the old pipeline and must # not be rendered as the outcome of this newly queued Operator # run (notably an old iteration-limit report). for stale_field in ( "lane", "builder_report", "auto_retry_attempts", "commit_hash", "review_decision", "review_report", "failure_stage", "failure_detail", "last_run_at", "operator_decision", "operator_reason", "human_action", ): stored.pop(stale_field, None) stored.update( { "source": "intent", "title": intent_cards[card_id]["title"], "phase": new_phase, "last_job_id": job_id, "last_run_status": "queued_operator", "runtime": config["runtime"], "model": config["model"], "reasoning_effort": config["reasoning_effort"], "pipeline_run_id": pipeline_run_id, "updated_at": now_iso(), } ) state["cards"][card_id] = stored save_state(state) return job_id if new_phase in operator_phases: raise PermissionError("diese Operator-Phase wird nur automatisch gesetzt") if current_phase in operator_phases: if new_phase != "geplant": raise PermissionError("Operator-Ergebnis muss zuerst nach 'geplant' zurückgesetzt werden") stored["phase"] = "geplant" stored["updated_at"] = now_iso() state["cards"][card_id] = stored save_state(state) return None # Split escalation column -- see # docs/intent/board-builder-limit-reached-auto-split.md. A sibling # escalation path off "in_umsetzung", not a further pipeline stage: # bypasses the generic rank/skip-stage guard below entirely, the # same way archiviert does above. Leaving "aufteilen_angefordert" or # the worker-only "aufgeteilt" outcome again is unguarded # bookkeeping, same treatment as leaving archiviert. if current_phase in ("aufteilen_angefordert", "aufgeteilt"): stored["phase"] = new_phase stored["updated_at"] = now_iso() if is_intent: stored["source"] = "intent" state["cards"][card_id] = stored save_state(state) return None if new_phase == "aufteilen_angefordert": if not is_intent: raise PermissionError("nur Intent-Karten können aufgeteilt werden") if stored.get("operator_generated"): raise PermissionError("Operator-Kinder dürfen nicht rekursiv aufgeteilt werden") if not escalation_exhausted(stored): raise PermissionError( "Aufteilen ist erst möglich, wenn die Karte am " "Iterationslimit hängen geblieben ist und ihre " "automatischen Eskalationsversuche bereits verbraucht sind" ) if repo_job_active(): raise PermissionError( "a board-agent job is already queued or running against this repository" ) runtime, extra = builder_column_payload() pipeline_run_id = stored.get("pipeline_run_id") or uuid.uuid4().hex job_id = write_job( card_id, intent_cards[card_id]["title"], intent_cards[card_id]["intent_path"], job_type="split", runtime=runtime, builder_report=stored.get("builder_report", ""), pipeline_run_id=pipeline_run_id, **extra, ) stored["pipeline_run_id"] = pipeline_run_id stored["last_job_id"] = job_id stored["last_run_status"] = "queued_split" stored["runtime"] = runtime stored["phase"] = new_phase stored["updated_at"] = now_iso() stored["source"] = "intent" state["cards"][card_id] = stored save_state(state) return job_id current_rank = PHASE_RANK[current_phase] new_rank = PHASE_RANK[new_phase] if new_rank > current_rank and new_rank - current_rank > 1: raise PermissionError( f"forward transition from '{current_phase}' to '{new_phase}' skips a pipeline stage" ) job_id = None if is_intent and new_phase in ("in_umsetzung", "in_umsetzung_leicht"): if current_phase != "geplant": raise PermissionError( f"card is '{current_phase}', trigger only allowed from 'geplant'" ) # "in_umsetzung"/"in_umsetzung_leicht" are the board's two # dedicated Builder columns ("schwer"/"leicht") -- see # docs/intent/board-builder-weighted-lanes.md. lane = "leicht" if new_phase == "in_umsetzung_leicht" else "schwer" # Guards the case the phase check alone misses: this same card # (dragged back to geplant and forward again), a research/review # job on the shared checkout, or an already-queued builder job # in the same lane. agent-codex/builder.py's flock is the hard # backstop if this check ever loses a race; this is the fast, # clear rejection for the normal case. blocked = builder_trigger_blocked(card_id, lane) if blocked: raise PermissionError(blocked) runtime, extra = builder_column_payload(new_phase) extra["lane"] = lane pipeline_run_id = uuid.uuid4().hex stored.update( { "source": "intent", "slug": card_id, "title": intent_cards[card_id]["title"], "intent_path": f"docs/intent/{card_id}.md", "lane": lane, } ) job_id = write_job( card_id, intent_cards[card_id]["title"], intent_cards[card_id]["intent_path"], runtime=runtime, pipeline_run_id=pipeline_run_id, **extra, ) stored["pipeline_run_id"] = pipeline_run_id stored["last_job_id"] = job_id stored["last_run_status"] = "queued" stored["runtime"] = runtime stored["model"] = extra.get("model", "") stored["reasoning_effort"] = extra.get("reasoning_effort", "") stored["iteration_limit"] = extra["iteration_limit"] stored["phase"] = new_phase stored["updated_at"] = now_iso() if is_intent: stored["source"] = "intent" state["cards"][card_id] = stored save_state(state) # The common case for a fan-out Builder child: a human drags it from # "review" to "bereit_fuer_deployment"/"planung_mit_feedback"/ # "deployed" once the reviewer's verdict is in -- see # docs/intent/board-subintent-fanout-synthesis.md. No-op for a card # without a parent_id or one whose new phase isn't terminal yet. fanout.maybe_synthesize(card_id) orchestration.progress_all(write_job, load_column_config()) return job_id class Handler(BaseHTTPRequestHandler): server_version = "board-agent/1" def _send_json(self, status, payload): body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _read_json_body(self): length = int(self.headers.get("Content-Length", "0") or "0") if length <= 0 or length > 8192: raise ValueError("invalid request body") raw = self.rfile.read(length) data = json.loads(raw.decode("utf-8")) if not isinstance(data, dict): raise ValueError("body must be a JSON object") return data def log_message(self, format_string, *args): sys.stderr.write("%s - %s\n" % (self.address_string(), format_string % args)) def do_GET(self): path = urlsplit(self.path).path if path in ("/", "/index.html", "/board.html"): self._serve_board_html() return if path == "/api/board": try: result = {"cards": merged_board(), "runtime": board_runtime(), "column_config": load_column_config()} except (OSError, ValueError) as error: print(f"Board state unavailable: {error}", file=sys.stderr) self._send_json(503, {"error": "Board state unavailable; no data was reset."}) return self._send_json(200, result) return if path == "/api/board/column-config": self._send_json(200, load_column_config()) return if path == "/api/board/runtime": self._send_json(200, {"runtime": board_runtime()}) return match = re.fullmatch(r"/api/board/cards/([^/]+)/telemetry", path) if match and SLUG_RE.fullmatch(match.group(1)): try: payload = read_card_telemetry(match.group(1)) except (LookupError, OSError, ValueError): self._send_json(404, {"error": "telemetry not found"}) return self._send_json(200, payload) return match = re.fullmatch(r"/api/board/pipelines/([A-Za-z0-9-]{1,128})/telemetry", path) if match: try: payload = telemetry.get_pipeline_run( Path(os.environ["BOARD_TELEMETRY_DB"]), match.group(1) ) except (OSError, ValueError): self._send_json(404, {"error": "telemetry not found"}) return self._send_json(200, payload) return match = re.fullmatch(r"/api/board/cards/([^/]+)/review", path) if match and SLUG_RE.fullmatch(match.group(1)): try: report = read_review_report(match.group(1)) except (LookupError, OSError, UnicodeError): self._send_json(404, {"error": "review report not found"}) return self._send_json(200, report) return match = re.fullmatch(r"/api/board/cards/([^/]+)/research", path) if match and SLUG_RE.fullmatch(match.group(1)): try: report = read_research_report(match.group(1)) except (LookupError, OSError, UnicodeError): self._send_json(404, {"error": "research report not found"}) return self._send_json(200, report) return match = re.fullmatch(r"/api/board/cards/([^/]+)/research-intent", path) if match and SLUG_RE.fullmatch(match.group(1)): try: intent = read_research_intent(match.group(1)) except (LookupError, OSError, UnicodeError): self._send_json(404, {"error": "research intent not found"}) return self._send_json(200, intent) return match = re.fullmatch(r"/api/board/cards/([^/]+)/intent", path) if match and SLUG_RE.fullmatch(match.group(1)): try: intent = read_intent(match.group(1)) except (LookupError, OSError, UnicodeError): self._send_json(404, {"error": "intent not found"}) return self._send_json(200, intent) return match = re.fullmatch(r"/api/board/agents/([a-z]+)", path) if match: try: prompt = read_agent_prompt(match.group(1)) except (LookupError, OSError, UnicodeError): self._send_json(404, {"error": "agent prompt not found"}) return self._send_json(200, prompt) return self._send_json(404, {"error": "not found"}) def _serve_board_html(self): html_path = Path(os.environ["BOARD_HTML_PATH"]) body = html_path.read_bytes() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_POST(self): path = urlsplit(self.path).path match = re.fullmatch(r"/api/board/cards/([a-z0-9-]+)/retry-deployment", path) if match: try: job_id = retry_deployment(match.group(1)) except (LookupError, PermissionError, ValueError) as error: self._send_json(409, {"error": str(error)}) return self._send_json(202, {"job_id": job_id}) return match = re.fullmatch(r"/api/board/cards/([a-z0-9-]+)/plan", path) if match: try: card_id = plan_card(match.group(1)) except (LookupError, PermissionError, ValueError) as error: self._send_json(409, {"error": str(error)}) return self._send_json(201, {"id": card_id}) return match = re.fullmatch(r"/api/board/column-config/([^/]+)", path) if match: column = match.group(1) if column not in COLUMN_CONFIG_DEFAULTS: self._send_json(404, {"error": "not found"}) return try: config = set_column_config(column, self._read_json_body()) except ValueError as error: self._send_json(400, {"error": str(error)}) return self._send_json(200, config) return if path == "/api/board/runtime": try: runtime = set_board_runtime(self._read_json_body().get("runtime")) except ValueError as error: self._send_json(400, {"error": str(error)}) return self._send_json(200, {"runtime": runtime}) return if path == "/api/board/ideas": try: payload = self._read_json_body() card_id = create_idea(str(payload.get("title", ""))) except ValueError as error: self._send_json(400, {"error": str(error)}) return self._send_json(201, {"id": card_id}) return if path == "/api/board/drafts": try: payload = self._read_json_body() card_id = create_draft(str(payload.get("topic", ""))) except ValueError as error: self._send_json(400, {"error": str(error)}) return self._send_json(201, {"id": card_id}) return match = re.fullmatch( r"/api/board/smoke-tests/(direct|split|remove-direct|remove-split)", path, ) if match: try: slug = create_smoke_test(match.group(1)) except ValueError as error: self._send_json(400, {"error": str(error)}) return self._send_json(201, {"id": slug}) return match = re.fullmatch(r"/api/board/cards/([^/]+)/fixer-answer", path) if match and SLUG_RE.fullmatch(match.group(1)): try: job_id = answer_fixer(match.group(1), self._read_json_body().get("answer")) except LookupError as error: self._send_json(404, {"error": str(error)}) return except PermissionError as error: self._send_json(409, {"error": str(error)}) return except ValueError as error: self._send_json(400, {"error": str(error)}) return self._send_json(200, {"status": "ok", "job_id": job_id}) return match = re.fullmatch(r"/api/board/cards/([^/]+)/draft-answer", path) if match and SLUG_RE.fullmatch(match.group(1)): try: job_id = answer_draft(match.group(1), self._read_json_body().get("answer")) except LookupError as error: self._send_json(404, {"error": str(error)}) return except PermissionError as error: self._send_json(409, {"error": str(error)}) return except ValueError as error: self._send_json(400, {"error": str(error)}) return self._send_json(200, {"status": "ok", "job_id": job_id}) return match = re.fullmatch(r"/api/board/cards/([^/]+)/draft-retry", path) if match and SLUG_RE.fullmatch(match.group(1)): try: job_id = retry_draft(match.group(1)) except LookupError as error: self._send_json(404, {"error": str(error)}) return except PermissionError as error: self._send_json(409, {"error": str(error)}) return self._send_json(200, {"status": "ok", "job_id": job_id}) return match = re.fullmatch(r"/api/board/cards/([^/]+)/apply-routing", path) if match and SLUG_RE.fullmatch(match.group(1)): try: phase = apply_routing_recommendation(match.group(1)) except PermissionError as error: self._send_json(409, {"error": str(error)}) return self._send_json(200, {"status": "ok", "phase": phase}) return match = re.fullmatch(r"/api/board/cards/([^/]+)/retry-review", path) if match and SLUG_RE.fullmatch(match.group(1)): # Body is optional: an explicit commit_hash is only needed for # a card whose Review failed before commit_hash started being # recorded at commit time (see retry_review()'s docstring) -- # every other caller just POSTs with no body at all, which # _read_json_body() rejects (no/zero Content-Length) the same # way it would for a genuinely malformed body; either way this # route treats that as "no override", not an error. try: commit_hash = self._read_json_body().get("commit_hash") except ValueError: commit_hash = None try: job_id = retry_review(match.group(1), commit_hash) except LookupError as error: self._send_json(404, {"error": str(error)}) return except PermissionError as error: self._send_json(409, {"error": str(error)}) return except ValueError as error: self._send_json(400, {"error": str(error)}) return self._send_json(200, {"status": "ok", "job_id": job_id}) return self._send_json(404, {"error": "not found"}) def do_PATCH(self): path = urlsplit(self.path).path parent_match = re.fullmatch(r"/api/board/cards/(" + CARD_ID_RE.pattern + r")/parent", path) if parent_match: child_id = parent_match.group(1) try: payload = self._read_json_body() parent_id = str(payload.get("parent_id", "")) link_parent(child_id, parent_id) except LookupError as error: self._send_json(404, {"error": str(error)}) return except ValueError as error: self._send_json(400, {"error": str(error)}) return self._send_json(200, {"status": "ok"}) return match = re.fullmatch(r"/api/board/cards/(" + CARD_ID_RE.pattern + r")", path) if not match: self._send_json(404, {"error": "not found"}) return card_id = match.group(1) try: payload = self._read_json_body() new_phase = str(payload.get("phase", "")) job_id = update_phase(card_id, new_phase, payload.get("task_type")) except LookupError as error: self._send_json(404, {"error": str(error)}) return except PermissionError as error: self._send_json(409, {"error": str(error)}) return except ValueError as error: self._send_json(400, {"error": str(error)}) return self._send_json(200, {"status": "ok", "job_id": job_id}) def do_DELETE(self): path = urlsplit(self.path).path if path == "/api/board/smoke-tests": try: result = delete_smoke_test_cards() except PermissionError as error: self._send_json(409, {"error": str(error)}) return self._send_json(200, {"status": "deleted", **result}) return match = re.fullmatch(r"/api/board/cards/(" + CARD_ID_RE.pattern + r")", path) if not match: self._send_json(404, {"error": "not found"}) return try: delete_manual_card(match.group(1)) except LookupError as error: self._send_json(404, {"error": str(error)}) return self._send_json(200, {"status": "deleted"}) def main(): host = os.environ.get("BOARD_HTTP_HOST", "127.0.0.1") port = int(os.environ.get("BOARD_HTTP_PORT", "8060")) Path(os.environ["BOARD_STATE_DIR"]).mkdir(parents=True, exist_ok=True, mode=0o700) try: orchestration.progress_all(write_job, load_column_config()) except (OSError, ValueError, RuntimeError) as error: print(f"Operator-Recovery konnte nicht fortgesetzt werden: {error}", file=sys.stderr) server = ThreadingHTTPServer((host, port), Handler) server.serve_forever() if __name__ == "__main__": main()