diff --git a/a2/.gitignore b/a2/.gitignore new file mode 100644 index 0000000..fc0defd --- /dev/null +++ b/a2/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.db +*.tmp diff --git a/a2/MISSION.md.template b/a2/MISSION.md.template new file mode 100644 index 0000000..f56d4f2 --- /dev/null +++ b/a2/MISSION.md.template @@ -0,0 +1,33 @@ +# Mission: {{MISSION_ID}} + +> **Hinweis:** Dies ist ein MARKDOWN-Template für Metadaten (Goal/Scope/ +> Constraints/Acceptance Criteria). Der aktive technische Zustand der Mission +> wird NIEMALS allein aus Markdown gelesen — die Authorität liegt in der +> `missions.db` (Mission-State) und im Kanban-Mirror (WP-Status). Siehe A2 §8. + +- **Mission-ID:** `{{MISSION_ID}}` +- **Titel:** {{TITLE}} +- **State:** {{STATE}} *(maßgeblich: missions.db)* +- **Erstellt:** {{CREATED_AT}} + +## Goal +{{GOAL}} + +## Scope +{{SCOPE}} + +## Constraints +{{CONSTRAINTS}} + +## Acceptance Criteria +{{ACCEPTANCE_CRITERIA}} + +## Work Packages +_(WPs werden in `missions.db` + Kanban-Mirror verwaltet; hier nur Lese-Referenz.)_ + +| WP-ID | Titel | State | +|-------|-------|-------| + +## Decisions / Blocker / Evidence +_(Wichtige Entscheidungen und Blocker-Beschreibungen werden hier notiert; +Transition-Evidence ist zusätzlich in `mission_events`/`wp_events` gespeichert.)_ diff --git a/a2/rq_mission.py b/a2/rq_mission.py new file mode 100644 index 0000000..ae3a1af --- /dev/null +++ b/a2/rq_mission.py @@ -0,0 +1,624 @@ +#!/usr/bin/env python3 +""" +Red Queen — A2: Persistentes Mission-State & Work-Package-State Management. + +Schichtung (siehe A2 §8 NATIVE vs MARKDOWN): + * MISSION-Ebene -> dieses Modul + `missions.db` (SQLite, stdlib sqlite3). + Missions-IDs, Titel/Goal/Scope/Constraints/Acceptance, + Mission-State (A1 State Machine), Transition-Evidence. + Der Mission-State und die WPs werden hier deterministisch + validiert. Die `missions.db` ist der PRIMAERE, durable + State fuer MISSION & WORK PACKAGE. + * WORK-PACKAGE -> Wird in `missions.db` persistiert UND (Best Effort) als + natives Kanban-Task gespiegelt, damit Dependencies und + Block nativen Hermes-Mechanismen nutzen. Die Kanban-Tasks + sind ein DURABLE MIRROR; die Authoritat (Fach-Transitions) + liegt in `missions.db`. Der Mirror ist optional aktivierbar + (kanban_db Pfad), so dass Tests ihn komplett isolieren oder + deaktivieren koennen. + +Idempotenz & Restart-Stabilitaet: + * Missions-ID `RQ-MISSION-YYYYMMDD-NNN` (NNN laufend pro Tag, Max+1 restart-stabil). + * WP-ID `-WP-NNN` (NNN laufend pro Mission). + * create mit gleicher ID / Idempotency-Key -> kein Duplikat. + * DONE->DONE erzeugt KEIN zweites Transition-Event. + * Doppelte Dependency -> kein Duplikat-Eintrag. + * Jede persistente Mutation laeuft in einer SQLite-Transaktion. + +Fail-Closed: + * Bei State-Inkonsistenz / unbekanntem State -> `RqError` (Code STATE_ERROR), + KEINE Mutation, Evidence wird gemeldet. + * Ungueltige Transition -> `RqError` (Code INVALID_TRANSITION), keine Mutation. + +KEINE Orchestrierung: kein Loop/Retry/Heartbeat/Cron in diesem Modul. +""" + +from __future__ import annotations + +import datetime +import json +import os +import sqlite3 +import subprocess +from pathlib import Path +from typing import Any, Dict, List, Optional + +from rq_state_machine import ( + is_mission_state, + is_wp_state, + mission_transition_allowed, + wp_transition_allowed, +) + +# --------------------------------------------------------------------------- # +# Fehler-Codes (maschinenlesbar) +# --------------------------------------------------------------------------- # +class RqError(Exception): + """Geworfener, maschinenlesbarer Fehler mit stabilem `code`.""" + + def __init__(self, code: str, message: str, detail: Optional[Dict[str, Any]] = None): + super().__init__(message) + self.code = code + self.message = message + self.detail = detail or {} + + def to_dict(self) -> Dict[str, Any]: + return {"code": self.code, "message": self.message, "detail": self.detail} + + +def _err(code: str, msg: str, **detail) -> RqError: + return RqError(code, msg, detail) + + +# --------------------------------------------------------------------------- # +# DB-Hilfsfunktionen +# --------------------------------------------------------------------------- # +def _connect(path: str) -> sqlite3.Connection: + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def _utcnow() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + + +# --------------------------------------------------------------------------- # +# Kanban-Mirror (Best Effort, isoliert per HERMES_KANBAN_DB) +# --------------------------------------------------------------------------- # +class KanbanMirror: + """Reflektiert WPs als native Kanban-Tasks (durable mirror). + + Nur aktiv, wenn `db` gesetzt und nicht 'off'. Aufrufe verwenden die + `hermes kanban` CLI mit `HERMES_KANBAN_DB` auf einen dedizierten DB-Pfad. + Fehler im Mirror sind NICHT fatal fuer die Missions-Transaktion: die + `missions.db` ist immer authoritativ. + """ + + def __init__(self, db: Optional[str]): + self.enabled = bool(db) and db.strip().lower() != "off" + self.db = Path(db).expanduser() if self.enabled else None + self._env = dict(os.environ) + if self.enabled: + self._env["HERMES_KANBAN_DB"] = str(self.db) + + def _run(self, args: List[str]) -> str: + cmd = ["hermes", "kanban", *args] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=self._env) + return (proc.stdout or "").strip() + except Exception: + return "" + + def _show_task(self, task_id: str) -> Optional[Dict[str, Any]]: + out = self._run(["show", task_id, "--json"]) + try: + return json.loads(out).get("task", {}) + except Exception: + return None + + def create(self, title: str, mission_id: str, wp_id: str) -> Optional[str]: + out = self._run( + ["create", title, "--idempotency-key", f"rq:{mission_id}:{wp_id}", "--json"] + ) + try: + return json.loads(out).get("id") + except Exception: + return None + + def link(self, parent_task: str, child_task: str) -> None: + if parent_task and child_task: + self._run(["link", parent_task, child_task]) + + def block(self, task_id: str, reason: str) -> None: + if task_id: + self._run(["block", task_id, "--kind", "needs_input", reason]) + + def unblock(self, task_id: str) -> None: + if task_id: + self._run(["unblock", task_id, "--reason", "unblocked by state machine"]) + + def complete(self, task_id: str, result: str) -> None: + if task_id: + self._run(["complete", task_id, "--result", result]) + + +# --------------------------------------------------------------------------- # +# Missions-Store +# --------------------------------------------------------------------------- # +class MissionStore: + """Persistenter, deterministischer MISSION & WP Store auf einer SQLite-DB.""" + + def __init__(self, db_path: str, mirror_db: Optional[str] = None): + self.db_path = db_path + self._ensure_schema() + self.mirror = KanbanMirror(mirror_db) + + # -- Schema ------------------------------------------------------------- + def _ensure_schema(self) -> None: + conn = _connect(self.db_path) + try: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS missions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + goal TEXT, + scope TEXT, + constraints TEXT, + acceptance_criteria TEXT, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS work_packages ( + id TEXT PRIMARY KEY, + mission_id TEXT NOT NULL REFERENCES missions(id), + title TEXT NOT NULL, + state TEXT NOT NULL, + kanban_task_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS wp_dependencies ( + mission_id TEXT NOT NULL, + wp_id TEXT NOT NULL, + depends_on TEXT NOT NULL, + PRIMARY KEY (mission_id, wp_id, depends_on) + ); + CREATE TABLE IF NOT EXISTS mission_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mission_id TEXT NOT NULL, + transition TEXT NOT NULL, + actor TEXT NOT NULL, + evidence TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS wp_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wp_id TEXT NOT NULL, + mission_id TEXT NOT NULL, + transition TEXT NOT NULL, + actor TEXT NOT NULL, + evidence TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_wp_mission ON work_packages(mission_id); + CREATE INDEX IF NOT EXISTS idx_me_mission ON mission_events(mission_id); + CREATE INDEX IF NOT EXISTS idx_we_wp ON wp_events(wp_id); + """ + ) + conn.commit() + finally: + conn.close() + + # -- row helpers ---------------------------------------------------------- + def _mission_row(self, conn, mission_id: str): + return conn.execute("SELECT * FROM missions WHERE id = ?", (mission_id,)).fetchone() + + def _wp_row(self, conn, wp_id: str): + return conn.execute("SELECT * FROM work_packages WHERE id = ?", (wp_id,)).fetchone() + + def _mission_exists(self, conn, mission_id: str) -> bool: + return conn.execute("SELECT 1 FROM missions WHERE id = ?", (mission_id,)).fetchone() is not None + + def _wp_exists(self, conn, wp_id: str) -> bool: + return conn.execute("SELECT 1 FROM work_packages WHERE id = ?", (wp_id,)).fetchone() is not None + + # -- ID-Erzeugung ---------------------------------------------------------- + def _new_mission_id(self, conn) -> str: + """Restart-stabile Mission-ID: RQ-MISSION-YYYYMMDD-NNN (Max+1 pro Tag). + + Der laufende Zaehler wird aus den bereits vorhandenen IDs des heutigen + Tages berechnet (Max+1). Das Parsen des Suffixes erfolgt in Python, + da SQLite hier kein `reverse()` bietet. + """ + today = datetime.date.today().strftime("%Y%m%d") + prefix = f"RQ-MISSION-{today}-" + rows = conn.execute("SELECT id FROM missions WHERE id LIKE ?", (prefix + "%",)).fetchall() + n = 0 + for r in rows: + suffix = r["id"][len(prefix):] + if suffix.isdigit(): + n = max(n, int(suffix)) + return f"{prefix}{n + 1:03d}" + + def _new_wp_id(self, conn, mission_id: str) -> str: + """Restart-stabile WP-ID: -WP-NNN (Max+1 pro Mission).""" + row = conn.execute( + "SELECT COALESCE(MAX(CAST(substr(id, instr(id,'-WP-') + 4) AS INTEGER)), 0) AS m " + "FROM work_packages WHERE mission_id = ?", + (mission_id,), + ).fetchone() + n = int(row["m"]) + 1 if row and row["m"] is not None else 1 + return f"{mission_id}-WP-{n:03d}" + + # -- Mission: Create ------------------------------------------------------ + def mission_create( + self, + title: str, + goal: Optional[str] = None, + scope: Optional[str] = None, + constraints: Optional[str] = None, + acceptance_criteria: Optional[str] = None, + mission_id: Optional[str] = None, + actor: str = "red-queen", + ) -> Dict[str, Any]: + if not title or not title.strip(): + raise _err("INVALID_ARGS", "mission title required") + conn = _connect(self.db_path) + try: + if mission_id: + if self._mission_exists(conn, mission_id): + return self._mission_read(conn, mission_id) # idempotent + mid = mission_id + else: + mid = self._new_mission_id(conn) + now = _utcnow() + conn.execute( + "INSERT INTO missions " + "(id,title,goal,scope,constraints,acceptance_criteria,state,created_at,updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?)", + (mid, title.strip(), goal, scope, constraints, acceptance_criteria, "CREATED", now, now), + ) + conn.execute( + "INSERT INTO mission_events (mission_id,transition,actor,evidence,created_at) " + "VALUES (?,?,?,?,?)", + (mid, "CREATED", actor, "mission created", now), + ) + conn.commit() + return self._mission_read(conn, mid) + finally: + conn.close() + + def _mission_read(self, conn, mission_id: str) -> Dict[str, Any]: + m = self._mission_row(conn, mission_id) + if m is None: + raise _err("UNKNOWN_MISSION", f"unknown mission id {mission_id!r}", mission_id=mission_id) + wps = [ + {"id": r["id"], "title": r["title"], "state": r["state"], "kanban_task_id": r["kanban_task_id"]} + for r in conn.execute( + "SELECT id,title,state,kanban_task_id FROM work_packages WHERE mission_id=? ORDER BY id", + (mission_id,), + ) + ] + deps = [ + {"wp_id": r["wp_id"], "depends_on": r["depends_on"]} + for r in conn.execute( + "SELECT wp_id, depends_on FROM wp_dependencies WHERE mission_id=? ORDER BY wp_id, depends_on", + (mission_id,), + ) + ] + return { + "id": m["id"], + "title": m["title"], + "goal": m["goal"], + "scope": m["scope"], + "constraints": m["constraints"], + "acceptance_criteria": m["acceptance_criteria"], + "state": m["state"], + "created_at": m["created_at"], + "updated_at": m["updated_at"], + "work_packages": wps, + "dependencies": deps, + } + + def mission_read(self, mission_id: str) -> Dict[str, Any]: + conn = _connect(self.db_path) + try: + return self._mission_read(conn, mission_id) + finally: + conn.close() + + def mission_state(self, mission_id: str) -> Dict[str, Any]: + conn = _connect(self.db_path) + try: + m = self._mission_row(conn, mission_id) + if m is None: + raise _err("UNKNOWN_MISSION", f"unknown mission {mission_id!r}", mission_id=mission_id) + return {"id": m["id"], "state": m["state"]} + finally: + conn.close() + + # -- Mission: transition -------------------------------------------------- + def mission_transition( + self, + mission_id: str, + target: str, + actor: str = "red-queen", + evidence: Optional[str] = None, + ) -> Dict[str, Any]: + if not is_mission_state(target): + raise _err("UNKNOWN_STATE", f"unknown mission state {target!r}", target=target) + conn = _connect(self.db_path) + try: + m = self._mission_row(conn, mission_id) + if m is None: + raise _err("UNKNOWN_MISSION", f"unknown mission {mission_id!r}", mission_id=mission_id) + current = m["state"] + if not is_mission_state(current): + raise _err( + "STATE_ERROR", + f"corrupt mission state {current!r}", + mission_id=mission_id, current=current, + ) + if current == target: + return { + "mission_id": mission_id, + "state": target, + "transition": f"{current}->{target}", + "idempotent": True, + } + if not mission_transition_allowed(current, target): + raise _err( + "INVALID_TRANSITION", + f"forbidden mission transition {current} -> {target}", + mission_id=mission_id, current=current, target=target, + ) + now = _utcnow() + conn.execute("UPDATE missions SET state=?, updated_at=? WHERE id=?", (target, now, mission_id)) + conn.execute( + "INSERT INTO mission_events (mission_id,transition,actor,evidence,created_at) " + "VALUES (?,?,?,?,?)", + (mission_id, f"{current}->{target}", actor, evidence, now), + ) + conn.commit() + return { + "mission_id": mission_id, + "state": target, + "transition": f"{current}->{target}", + "idempotent": False, + } + finally: + conn.close() + + def mission_pause(self, mission_id, actor="red-queen", evidence=None): + return self.mission_transition(mission_id, "PAUSED", actor, evidence) + + def mission_block(self, mission_id, actor="red-queen", evidence=None): + return self.mission_transition(mission_id, "BLOCKED", actor, evidence) + + def mission_resume(self, mission_id, actor="red-queen", evidence=None): + return self.mission_transition(mission_id, "RUNNING", actor, evidence) + + def mission_complete(self, mission_id, actor="red-queen", evidence=None): + """REVIEW -> COMPLETED. Rejected, solange ein Pflicht-WP nicht DONE ist.""" + conn = _connect(self.db_path) + try: + m = self._mission_row(conn, mission_id) + if m is None: + raise _err("UNKNOWN_MISSION", f"unknown mission {mission_id!r}", mission_id=mission_id) + if m["state"] == "COMPLETED": + # idempotent: erneutes complete ist ein No-op + return { + "mission_id": mission_id, + "state": "COMPLETED", + "transition": "COMPLETED->COMPLETED", + "idempotent": True, + } + if m["state"] != "REVIEW": + raise _err( + "INVALID_TRANSITION", + f"mission_complete requires REVIEW, got {m['state']}", + mission_id=mission_id, current=m["state"], + ) + open_wps = conn.execute( + "SELECT id,state FROM work_packages WHERE mission_id=? AND state <> 'DONE' ORDER BY id", + (mission_id,), + ).fetchall() + if open_wps: + raise _err( + "MISSION_INCOMPLETE_WPS", + "mission has open (non-DONE) work packages", + mission_id=mission_id, + open_wps=[{"id": r["id"], "state": r["state"]} for r in open_wps], + ) + return self.mission_transition(mission_id, "COMPLETED", actor, evidence) + finally: + conn.close() + + # -- WP: create ------------------------------------------------------------ + def wp_create(self, mission_id: str, title: str, wp_id: Optional[str] = None, actor: str = "red-queen"): + if not title or not title.strip(): + raise _err("INVALID_ARGS", "wp title required") + conn = _connect(self.db_path) + try: + if not self._mission_exists(conn, mission_id): + raise _err("UNKNOWN_MISSION", f"unknown mission {mission_id!r}", mission_id=mission_id) + if wp_id: + if self._wp_exists(conn, wp_id): + return self._wp_read(conn, wp_id) # idempotent + wid = wp_id + else: + wid = self._new_wp_id(conn, mission_id) + now = _utcnow() + conn.execute( + "INSERT INTO work_packages (id,mission_id,title,state,created_at,updated_at) " + "VALUES (?,?,?,?,?,?)", + (wid, mission_id, title.strip(), "TODO", now, now), + ) + conn.execute( + "INSERT INTO wp_events (wp_id,mission_id,transition,actor,evidence,created_at) " + "VALUES (?,?,?,?,?,?)", + (wid, mission_id, "TODO", actor, "wp created", now), + ) + conn.commit() + # Kanban-Mirror (best effort) + if self.mirror.enabled: + ktask = self.mirror.create(title.strip(), mission_id, wid) + if ktask: + conn.execute("UPDATE work_packages SET kanban_task_id=? WHERE id=?", (ktask, wid)) + conn.commit() + return self._wp_read(conn, wid) + finally: + conn.close() + + def _wp_read(self, conn, wp_id: str) -> Dict[str, Any]: + w = self._wp_row(conn, wp_id) + if w is None: + raise _err("UNKNOWN_WP", f"unknown wp {wp_id!r}", wp_id=wp_id) + return { + "id": w["id"], + "mission_id": w["mission_id"], + "title": w["title"], + "state": w["state"], + "kanban_task_id": w["kanban_task_id"], + "created_at": w["created_at"], + "updated_at": w["updated_at"], + } + + def wp_read(self, wp_id: str) -> Dict[str, Any]: + conn = _connect(self.db_path) + try: + return self._wp_read(conn, wp_id) + finally: + conn.close() + + def wp_state(self, wp_id: str) -> Dict[str, Any]: + conn = _connect(self.db_path) + try: + w = self._wp_row(conn, wp_id) + if w is None: + raise _err("UNKNOWN_WP", f"unknown wp {wp_id!r}", wp_id=wp_id) + return {"id": w["id"], "state": w["state"]} + finally: + conn.close() + + # -- WP: transition --------------------------------------------------------- + def wp_transition(self, wp_id, target, actor="red-queen", evidence=None): + if not is_wp_state(target): + raise _err("UNKNOWN_STATE", f"unknown wp state {target!r}", target=target) + conn = _connect(self.db_path) + try: + w = self._wp_row(conn, wp_id) + if w is None: + raise _err("UNKNOWN_WP", f"unknown wp {wp_id!r}", wp_id=wp_id) + current = w["state"] + if not is_wp_state(current): + raise _err( + "STATE_ERROR", + f"corrupt wp state {current!r}", + wp_id=wp_id, current=current, + ) + if current == target: + return {"id": wp_id, "state": target, "transition": f"{current}->{target}", "idempotent": True} + if not wp_transition_allowed(current, target): + raise _err( + "INVALID_TRANSITION", + f"forbidden wp transition {current} -> {target}", + wp_id=wp_id, current=current, target=target, + ) + if target == "DONE" and current != "CHECKING": + raise _err( + "INVALID_TRANSITION", + f"wp can only reach DONE via CHECKING (current {current})", + wp_id=wp_id, current=current, target="DONE", + ) + if target == "DONE": + open_deps = self._open_deps(conn, w["mission_id"], wp_id) + if open_deps: + raise _err( + "WP_OPEN_DEPENDENCY", + "wp has open dependency", + wp_id=wp_id, dependencies=open_deps, + ) + now = _utcnow() + conn.execute("UPDATE work_packages SET state=?, updated_at=? WHERE id=?", (target, now, wp_id)) + conn.execute( + "INSERT INTO wp_events (wp_id,mission_id,transition,actor,evidence,created_at) " + "VALUES (?,?,?,?,?,?)", + (wp_id, w["mission_id"], f"{current}->{target}", actor, evidence, now), + ) + conn.commit() + self._mirror_sync(w["mission_id"], wp_id, current, target, evidence) + return {"id": wp_id, "state": target, "transition": f"{current}->{target}", "idempotent": False} + finally: + conn.close() + + def wp_block(self, wp_id, reason="blocked", actor="red-queen"): + return self.wp_transition(wp_id, "BLOCKED", actor, reason) + + def wp_complete(self, wp_id, result=None, actor="red-queen"): + return self.wp_transition(wp_id, "DONE", actor, result) + + # -- WP: dependency ---------------------------------------------------------- + def wp_dependency(self, mission_id: str, wp_id: str, depends_on: str) -> Dict[str, Any]: + conn = _connect(self.db_path) + try: + if not self._wp_exists(conn, wp_id): + raise _err("UNKNOWN_WP", f"unknown wp {wp_id!r}", wp_id=wp_id) + if not self._wp_exists(conn, depends_on): + raise _err("UNKNOWN_WP", f"unknown dependency wp {depends_on!r}", wp_id=depends_on) + w = self._wp_row(conn, wp_id) + d = self._wp_row(conn, depends_on) + if w["mission_id"] != mission_id or d["mission_id"] != mission_id: + raise _err( + "INVALID_DEPENDENCY", + "wp and dependency must belong to same mission", + mission_id=mission_id, wp_id=wp_id, depends_on=depends_on, + ) + if wp_id == depends_on: + raise _err("INVALID_DEPENDENCY", "wp cannot depend on itself", wp_id=wp_id) + conn.execute( + "INSERT OR IGNORE INTO wp_dependencies (mission_id,wp_id,depends_on) VALUES (?,?,?)", + (mission_id, wp_id, depends_on), + ) + conn.commit() + if self.mirror.enabled: + wt = self._wp_row(conn, wp_id) + dt = self._wp_row(conn, depends_on) + if wt["kanban_task_id"] and dt["kanban_task_id"]: + self.mirror.link(dt["kanban_task_id"], wt["kanban_task_id"]) + return {"mission_id": mission_id, "wp_id": wp_id, "depends_on": depends_on} + finally: + conn.close() + + # -- interne Helfer ----------------------------------------------------------- + def _open_deps(self, conn, mission_id: str, wp_id: str) -> List[Dict[str, Any]]: + rows = conn.execute( + "SELECT wd.depends_on, w.state FROM wp_dependencies wd " + "JOIN work_packages w ON w.id = wd.depends_on " + "WHERE wd.mission_id=? AND wd.wp_id=? AND w.state <> 'DONE'", + (mission_id, wp_id), + ).fetchall() + return [{"id": r["depends_on"], "state": r["state"]} for r in rows] + + def _mirror_sync(self, mission_id: str, wp_id: str, current: str, target: str, evidence: Optional[str]): + if not self.mirror.enabled: + return + conn = _connect(self.db_path) + try: + w = self._wp_row(conn, wp_id) + if w is None or not w["kanban_task_id"]: + return + k = w["kanban_task_id"] + if target == "BLOCKED": + self.mirror.block(k, evidence or "blocked by state machine") + elif current == "BLOCKED" and target != "BLOCKED": + self.mirror.unblock(k) + elif target == "DONE": + self.mirror.complete(k, evidence or "done") + finally: + conn.close() diff --git a/a2/rq_mission_cli.py b/a2/rq_mission_cli.py new file mode 100644 index 0000000..5444496 --- /dev/null +++ b/a2/rq_mission_cli.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +Red Queen — A2 CLI. Dünner, deterministischer Kommandozeilen-Zugriff auf den +MissionStore. Maschinenlesbar via `--json`. + +Hinweis: Der A2-Build definiert KEINE autonome Orchestrierung — dieses CLI ruft +nur die vom Store bereitgestellten atomaren Operationen auf. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Optional + +from rq_mission import MissionStore, RqError + + +def _store(args) -> MissionStore: + db = args.db or os.environ.get("RQ_MISSIONS_DB") or "missions.db" + mirror = args.mirror + return MissionStore(db, mirror_db=mirror) + + +def _emit(args, obj) -> None: + if args.json: + print(json.dumps(obj, indent=2, ensure_ascii=False)) + else: + if isinstance(obj, dict) and "id" in obj: + print(obj["id"]) + else: + print(json.dumps(obj, ensure_ascii=False)) + + +def _fail(e: RqError, args) -> None: + if args.json: + print(json.dumps(e.to_dict(), indent=2)) + else: + print(f"ERROR [{e.code}]: {e.message}") + sys.exit(2) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="rq_mission", description="Red Queen A2 Mission/WP CLI") + p.add_argument("--db", help="Pfad zur missions.db (default: $RQMISSIONS_DB oder missions.db)") + p.add_argument("--mirror", help="Kanban-DB-Pfad fuer nativen Mirror ('off' deaktiviert)") + p.add_argument("--json", action="store_true", help="JSON-Ausgabe") + sub = p.add_subparsers(dest="cmd", required=True) + + # mission_create + mc = sub.add_parser("mission_create") + mc.add_argument("--title", required=True) + mc.add_argument("--goal") + mc.add_argument("--scope") + mc.add_argument("--constraints") + mc.add_argument("--acceptance") + mc.add_argument("--id", dest="mission_id") + mc.add_argument("--actor", default="red-queen") + + # mission_read / state + mr = sub.add_parser("mission_read"); mr.add_argument("mission_id") + ms = sub.add_parser("mission_state"); ms.add_argument("mission_id") + + # mission transitions + mt = sub.add_parser("mission_transition") + mt.add_argument("--mission", dest="mission_id", required=True) + mt.add_argument("--target", required=True) + mt.add_argument("--evidence") + mt.add_argument("--actor", default="red-queen") + sub.add_parser("mission_pause").add_argument("mission_id") + sub.add_parser("mission_block").add_argument("mission_id") + sub.add_parser("mission_resume").add_argument("mission_id") + sub.add_parser("mission_complete").add_argument("mission_id") + + # wp_create + wc = sub.add_parser("wp_create") + wc.add_argument("--mission", dest="mission_id", required=True) + wc.add_argument("--title", required=True) + wc.add_argument("--wp-id", dest="wp_id") + wc.add_argument("--actor", default="red-queen") + + wr = sub.add_parser("wp_read"); wr.add_argument("wp_id") + ws = sub.add_parser("wp_state"); ws.add_argument("wp_id") + wt = sub.add_parser("wp_transition") + wt.add_argument("--wp", dest="wp_id", required=True) + wt.add_argument("--target", required=True) + wt.add_argument("--evidence") + wt.add_argument("--actor", default="red-queen") + wd = sub.add_parser("wp_dependency") + wd.add_argument("--mission", dest="mission_id", required=True) + wd.add_argument("--wp", dest="wp_id", required=True) + wd.add_argument("--depends-on", required=True) + wb = sub.add_parser("wp_block") + wb.add_argument("wp_id") + wb.add_argument("--reason", default="blocked") + wc2 = sub.add_parser("wp_complete") + wc2.add_argument("wp_id") + wc2.add_argument("--result") + return p + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + try: + s = _store(args) + if args.cmd == "mission_create": + r = s.mission_create(args.title, goal=args.goal, scope=args.scope, + constraints=args.constraints, + acceptance_criteria=args.acceptance, + mission_id=args.mission_id, actor=args.actor) + elif args.cmd == "mission_read": + r = s.mission_read(args.mission_id) + elif args.cmd == "mission_state": + r = s.mission_state(args.mission_id) + elif args.cmd == "mission_transition": + r = s.mission_transition(args.mission_id, args.target, actor=args.actor, evidence=args.evidence) + elif args.cmd == "mission_pause": + r = s.mission_pause(args.mission_id) + elif args.cmd == "mission_block": + r = s.mission_block(args.mission_id) + elif args.cmd == "mission_resume": + r = s.mission_resume(args.mission_id) + elif args.cmd == "mission_complete": + r = s.mission_complete(args.mission_id) + elif args.cmd == "wp_create": + r = s.wp_create(args.mission_id, args.title, wp_id=args.wp_id, actor=args.actor) + elif args.cmd == "wp_read": + r = s.wp_read(args.wp_id) + elif args.cmd == "wp_state": + r = s.wp_state(args.wp_id) + elif args.cmd == "wp_transition": + r = s.wp_transition(args.wp_id, args.target, actor=args.actor, evidence=args.evidence) + elif args.cmd == "wp_dependency": + r = s.wp_dependency(args.mission_id, args.wp_id, args.depends_on) + elif args.cmd == "wp_block": + r = s.wp_block(args.wp_id, reason=args.reason) + elif args.cmd == "wp_complete": + r = s.wp_complete(args.wp_id, result=args.result) + else: + _emit(args, {"error": f"unknown command {args.cmd}"}) + return 1 + _emit(args, r) + return 0 + except RqError as e: + _fail(e, args) + except Exception as e: # noqa + _fail(RqError("INTERNAL_ERROR", str(e)), args) + return 2 # unreachable normally; _fail exits + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/a2/rq_state_machine.py b/a2/rq_state_machine.py new file mode 100644 index 0000000..1b8150e --- /dev/null +++ b/a2/rq_state_machine.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Red Queen — A2: Deterministische Zustandsmaschinen (MISSION + WORK PACKAGE). + +Single source of truth fuer alle erlaubten/verbotenen Transitions, gemaeß +`red-queen-architecture/MISSION_STATE_MACHINE.md` (autoritativer Vertrag). + +Dieses Modul enthaelt KEINE Persistenz und KEINE Orchestrierung — nur die +reinen State-Mengen und Transition-Tabellen. Die Anwendung (Persistenz, +Validierung, Kanban-Mirror) liegt in `rq_mission.py`. + +Transitions sind deterministisch: fuer ein gegebenes (von, nach) Paar gibt es +genau eine Antwort (erlaubt / verboten). +""" + +from __future__ import annotations + +from typing import Dict, FrozenSet, Set, Tuple + +# --------------------------------------------------------------------------- # +# State-Mengen +# --------------------------------------------------------------------------- # +MISSION_STATES: FrozenSet[str] = frozenset( + { + "CREATED", + "PLANNING", + "READY", + "RUNNING", + "REVIEW", + "COMPLETED", + "BLOCKED", + "PAUSED", + "FAILED", + "CANCELLED", + "ESCALATED", + } +) + +WP_STATES: FrozenSet[str] = frozenset( + { + "TODO", + "READY", + "IN_PROGRESS", + "CHECKING", + "DONE", + "FAILED", + "BLOCKED", + "ESCALATED", + } +) + +# Endzustaende (keine Reaktivierung ohne Neuanlage). +MISSION_TERMINAL: FrozenSet[str] = frozenset({"FAILED", "CANCELLED"}) +WP_TERMINAL: FrozenSet[str] = frozenset({"FAILED"}) # DONE ist terminal im +# Sinne "nicht mehr normal weiter", aber idempotent akzeptiert. + +# --------------------------------------------------------------------------- +# Transition-Tabellen — MISSION +# --------------------------------------------------------------------------- +# MISSION_STATE_MACHINE.md §4 (Mission): (von, nach) -> erlaubt +MISSION_TRANSITIONS: Set[Tuple[str, str]] = { + ("CREATED", "PLANNING"), + ("PLANNING", "READY"), + ("PLANNING", "BLOCKED"), + ("READY", "RUNNING"), + ("RUNNING", "REVIEW"), + ("REVIEW", "COMPLETED"), + ("REVIEW", "RUNNING"), + ("RUNNING", "PAUSED"), + ("PAUSED", "RUNNING"), + ("RUNNING", "BLOCKED"), + ("BLOCKED", "PAUSED"), + ("BLOCKED", "RUNNING"), + ("BLOCKED", "ESCALATED"), + ("PAUSED", "CANCELLED"), + ("RUNNING", "FAILED"), + ("RUNNING", "CANCELLED"), + ("BLOCKED", "CANCELLED"), + ("ESCALATED", "RUNNING"), + ("ESCALATED", "CANCELLED"), + ("ESCALATED", "COMPLETED"), +} + +# --------------------------------------------------------------------------- +# Transition-Tabellen — WORK PACKAGE +# --------------------------------------------------------------------------- +WP_TRANSITIONS: Set[Tuple[str, str]] = { + ("TODO", "READY"), + ("READY", "IN_PROGRESS"), + ("IN_PROGRESS", "CHECKING"), + ("CHECKING", "DONE"), + ("CHECKING", "IN_PROGRESS"), + ("IN_PROGRESS", "FAILED"), + ("IN_PROGRESS", "BLOCKED"), + ("BLOCKED", "IN_PROGRESS"), + ("BLOCKED", "ESCALATED"), + ("ESCALATED", "IN_PROGRESS"), + ("ESCALATED", "FAILED"), +} + +# --------------------------------------------------------------------------- +# API +# --------------------------------------------------------------------------- + + +def is_mission_state(state: str) -> bool: + return state in MISSION_STATES + + +def is_wp_state(state: str) -> bool: + return state in WP_STATES + + +def mission_transition_allowed(current: str, target: str) -> bool: + """Deterministische Antwort, ob (current -> target) erlaubt ist.""" + return (current, target) in MISSION_TRANSITIONS + + +def wp_transition_allowed(current: str, target: str) -> bool: + """Deterministische Antwort, ob (current -> target) erlaubt ist.""" + return (current, target) in WP_TRANSITIONS diff --git a/a2/test_a2.py b/a2/test_a2.py new file mode 100644 index 0000000..c664333 --- /dev/null +++ b/a2/test_a2.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +""" +Red Queen — A2 Testsuite (deterministisch, isoliert). + +Nutzt ausschliesslich temporaere DBs: + * missions.db -> temp Pfad (pro Testlauf neu) + * Kanban-Mirror -> eigener temp `HERMES_KANBAN_DB` Pfad (NICHT /opt/data/kanban.db) + +Lauf: + python3 test_a2.py +Exit-Code 0 = alle Tests gruen; 1 = mindestens ein Fehler. + +Deckt A2 §18 ab: create/read/valid+invalid transition, wp create/dependency/ +block/complete, mission complete, idempotency, unknown id, unknown state, +RESTART-PERSISTENCE, state consistency, negative-tests (§13), corruption (§14). +""" + +from __future__ import annotations + +import sqlite3 +import sys +import tempfile +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from rq_mission import MissionStore, RqError # noqa: E402 + +PASS = 0 +FAIL = 0 +FAILURES = [] + + +def check(name: str, cond: bool, extra: str = ""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" [PASS] {name}") + else: + FAIL += 1 + FAILURES.append(name) + print(f" [FAIL] {name} {extra}") + + +def expect_err(name: str, fn, code: str, fragment: str = ""): + try: + fn() + except RqError as e: + ok = e.code == code and (not fragment or fragment in e.message) + check(name, ok, f"got code={e.code} msg={e.message!r}") + return e + except Exception as e: # noqa + check(name, False, f"unexpected {type(e).__name__}: {e}") + return None + check(name, False, "no error raised") + return None + + +def fresh_store(): + d = tempfile.mkdtemp(prefix="a2test_") + return MissionStore(str(Path(d) / "missions.db")), d + + +def _drive_wp_to_done(s, wid): + for t in ("READY", "IN_PROGRESS", "CHECKING"): + s.wp_transition(wid, t) + s.wp_complete(wid) + + +def _drive_mission_to_review(s, mid): + for t in ("PLANNING", "READY", "RUNNING", "REVIEW"): + s.mission_transition(mid, t) + + +# --------------------------------------------------------------------------- # +# Tests +# --------------------------------------------------------------------------- # +def test_mission_create_read(): + print("\n== create/read ==") + s, _ = fresh_store() + m = s.mission_create("A2 mission", goal="g", scope="sc", + constraints="c", acceptance_criteria="ac") + check("mission_create returns CREATED", m["state"] == "CREATED", m) + check("mission id prefix", m["id"].startswith("RQ-MISSION-"), m["id"]) + r = s.mission_read(m["id"]) + check("mission_read roundtrip", r["id"] == m["id"] and r["state"] == "CREATED") + check("mission_read metadata", r["goal"] == "g" and r["acceptance_criteria"] == "ac") + + +def test_mission_id_sequence(): + print("\n== mission id sequence (Max+1) ==") + s, _ = fresh_store() + ids = [int(s.mission_create("a")["id"].split("-")[-1]) for _ in range(3)] + check("ids monotonic increasing", ids[0] < ids[1] < ids[2], ids) + + +def test_valid_mission_transitions(): + print("\n== valid mission transitions ==") + s, _ = fresh_store() + m = s.mission_create("v") + mid = m["id"] + seq = ["PLANNING", "READY", "RUNNING", "PAUSED", "RUNNING", "REVIEW"] + st = "CREATED" + for t in seq: + r = s.mission_transition(mid, t) + check(f"{st}->{t}", r["state"] == t, r) + st = t + + +def test_invalid_mission_transition(): + print("\n== invalid mission transition ==") + s, _ = fresh_store() + m = s.mission_create("x") + mid = m["id"] + expect_err("CREATED->RUNNING rejected", lambda: s.mission_transition(mid, "RUNNING"), + "INVALID_TRANSITION") + check("state unchanged", s.mission_state(mid)["state"] == "CREATED") + + +def test_wp_transition_flow(): + print("\n== wp happy-path to DONE ==") + s, _ = fresh_store() + m = s.mission_create("wpf") + w = s.wp_create(m["id"], "w") + for t in ("READY", "IN_PROGRESS", "CHECKING"): + s.wp_transition(w["id"], t) + s.wp_complete(w["id"]) + check("wp ends DONE", s.wp_state(w["id"])["state"] == "DONE") + r = s.wp_complete(w["id"]) + check("wp DONE->DONE idempotent", r["idempotent"] is True, r) + + +def test_wp_requires_checking_to_done(): + print("\n== wp cannot skip CHECKING to DONE ==") + s, _ = fresh_store() + m = s.mission_create("c") + w = s.wp_create(m["id"], "w") + expect_err("READY->DONE rejected", lambda: s.wp_transition(w["id"], "DONE"), + "INVALID_TRANSITION") + check("wp still TODO", s.wp_state(w["id"])["state"] == "TODO") + + +def test_dependency_gate(): + print("\n== dependency gate ==") + s, _ = fresh_store() + m = s.mission_create("dep") + wc = s.wp_create(m["id"], "child") + wp = s.wp_create(m["id"], "parent") + s.wp_dependency(m["id"], wc["id"], wp["id"]) + for st in ("READY", "IN_PROGRESS", "CHECKING"): + s.wp_transition(wc["id"], st) + expect_err("child DONE while parent open", lambda: s.wp_complete(wc["id"]), + "WP_OPEN_DEPENDENCY") + check("child not DONE", s.wp_state(wc["id"])["state"] == "CHECKING") + + +def test_mission_complete_requires_done_wps(): + print("\n== mission complete requires done WPs ==") + s, _ = fresh_store() + m = s.mission_create("mc") + w = s.wp_create(m["id"], "w") + _drive_mission_to_review(s, m["id"]) + expect_err("complete with open WP", lambda: s.mission_complete(m["id"]), + "MISSION_INCOMPLETE_WPS") + check("mission stays REVIEW", s.mission_state(m["id"])["state"] == "REVIEW") + _drive_wp_to_done(s, w["id"]) + r = s.mission_complete(m["id"]) + check("mission complete after WPs done", r["state"] == "COMPLETED", r) + + +def test_idempotency(): + print("\n== idempotency ==") + s, _ = fresh_store() + m = s.mission_create("idem") + mid = m["id"] + m2 = s.mission_create("idem2", mission_id=mid) + check("mission explicit-id idempotent", m2["id"] == mid, m2) + w = s.wp_create(mid, "w", wp_id=f"{mid}-WP-1") + w2 = s.wp_create(mid, "w", wp_id=f"{mid}-WP-1") + check("wp explicit-id idempotent", w2["id"] == w["id"], w2) + wa = s.wp_create(mid, "wa") + wb = s.wp_create(mid, "wb") + s.wp_dependency(mid, wa["id"], wb["id"]) + s.wp_dependency(mid, wa["id"], wb["id"]) # duplicate + rd = s.mission_read(mid) + count = sum(1 for d in rd["dependencies"] + if d["wp_id"] == wa["id"] and d["depends_on"] == wb["id"]) + check("double dependency no dup row", count == 1, rd["dependencies"]) + + +def test_unknown_id_and_state(): + print("\n== unknown id/state ==") + s, _ = fresh_store() + expect_err("mission_read unknown", lambda: s.mission_read("RQ-NOPE"), "UNKNOWN_MISSION") + expect_err("mission_transition unknown", lambda: s.mission_transition("RQ-NOPE", "PLANNING"), + "UNKNOWN_MISSION") + expect_err("wp_read unknown", lambda: s.wp_read("RQ-NOPE-WP-1"), "UNKNOWN_WP") + expect_err("unknown mission state", lambda: s.mission_transition("RQ-NOPE", "NOPE"), + "UNKNOWN_STATE") + m = s.mission_create("u") + expect_err("unknown wp state", lambda: s.wp_transition("RQ-NOPE", "NOPE"), "UNKNOWN_STATE") + + +def test_restart_persistence(): + print("\n== restart-persistence ==") + d = tempfile.mkdtemp(prefix="a2r_") + mdb = str(Path(d) / "missions.db") + s1 = MissionStore(mdb) + m = s1.mission_create("restart") + mid = m["id"] + w1 = s1.wp_create(mid, "w1") + w2 = s1.wp_create(mid, "w2") + s1.wp_dependency(mid, w1["id"], w2["id"]) + _drive_wp_to_done(s1, w2["id"]) + _drive_wp_to_done(s1, w1["id"]) + _drive_mission_to_review(s1, mid) + s1.mission_complete(mid) + + # "Restart": neues Store-Objekt, gleiche DB + s2 = MissionStore(mdb) + r = s2.mission_read(mid) + check("restart mission state identical", r["state"] == "COMPLETED", r["state"]) + check("restart has 2 WPs", len(r["work_packages"]) == 2, r["work_packages"]) + check("restart all WPs DONE", all(x["state"] == "DONE" for x in r["work_packages"])) + check("restart dependency intact", + any(x["wp_id"] == w1["id"] and x["depends_on"] == w2["id"] for x in r["dependencies"]), + r["dependencies"]) + c2 = s2.mission_complete(mid) + check("restart re-complete idempotent", c2["idempotent"] is True, c2) + w3 = s2.wp_create(mid, "w3") + check("wp id continues after restart", w3["id"] == f"{mid}-WP-003", w3["id"]) + + +def test_fail_closed_no_mutation(): + print("\n== fail-closed: no mutation on rejection ==") + s, _ = fresh_store() + m = s.mission_create("fc") + mid = m["id"] + w = s.wp_create(mid, "w") + expect_err("invalid mission transition", lambda: s.mission_transition(mid, "RUNNING"), + "INVALID_TRANSITION") + check("mission unchanged", s.mission_state(mid)["state"] == "CREATED") + expect_err("invalid wp transition", lambda: s.wp_transition(w["id"], "DONE"), + "INVALID_TRANSITION") + check("wp unchanged", s.wp_state(w["id"])["state"] == "TODO") + try: + s.mission_transition(mid, "RUNNING") + except RqError as e: + d = e.to_dict() + check("error code machine-readable", d["code"] == "INVALID_TRANSITION", d) + check("error has detail", "current" in d["detail"], d) + + +def test_state_error_corrupt_state(): + print("\n== corruption: STATE_ERROR fail-closed ==") + s, d = fresh_store() + m = s.mission_create("corrupt") + mid = m["id"] + w = s.wp_create(mid, "w") + wid = w["id"] + db = str(Path(d) / "missions.db") + # Korrupten Mission-State direkt in DB setzen + with sqlite3.connect(db) as c: + c.execute("UPDATE missions SET state='GARBAGE' WHERE id=?", (mid,)) + e = expect_err("corrupt mission -> STATE_ERROR", + lambda: s.mission_transition(mid, "PLANNING"), "STATE_ERROR") + if e is not None: + check("STATE_ERROR detail has current", + e.to_dict().get("detail", {}).get("current") == "GARBAGE", e.to_dict()) + check("mission state unchanged (still corrupt, no mutation)", + s.mission_state(mid)["state"] == "GARBAGE") + # Korrupten WP-State setzen + with sqlite3.connect(db) as c: + c.execute("UPDATE work_packages SET state='JUNK' WHERE id=?", (wid,)) + expect_err("corrupt wp -> STATE_ERROR", lambda: s.wp_transition(wid, "DONE"), "STATE_ERROR") + check("wp state unchanged (no mutation)", s.wp_state(wid)["state"] == "JUNK") + + +def test_mirror_isolation(): + print("\n== kanban mirror isolated ==") + d = tempfile.mkdtemp(prefix="a2mirror_") + kdb = str(Path(d) / "kanban_test.db") + s = MissionStore(str(Path(d) / "missions.db"), mirror_db=kdb) + m = s.mission_create("mirror") + w = s.wp_create(m["id"], "wmirror") + check("wp has kanban_task_id", bool(w["kanban_task_id"]), w) + for st in ("READY", "IN_PROGRESS", "CHECKING"): + s.wp_transition(w["id"], st) + s.wp_complete(w["id"]) + check("wp DONE", s.wp_state(w["id"])["state"] == "DONE") + check("test kanban db created", Path(kdb).exists(), kdb) + check("productive kanban.db untouched", kdb != "/opt/data/kanban.db") + + +def test_negative_completed_mission_rejects(): + print("\n== negative: completed cannot go active ==") + s, _ = fresh_store() + m = s.mission_create("neg") + mid = m["id"] + w = s.wp_create(mid, "w") + _drive_mission_to_review(s, mid) + _drive_wp_to_done(s, w["id"]) + s.mission_complete(mid) + expect_err("COMPLETED->RUNNING rejected", lambda: s.mission_transition(mid, "RUNNING"), + "INVALID_TRANSITION") + r = s.mission_transition(mid, "COMPLETED") + check("COMPLETED->COMPLETED idempotent", r["idempotent"] is True, r) + + +def test_wp_block_flow(): + print("\n== wp block/undo ==") + s, _ = fresh_store() + m = s.mission_create("blk") + w = s.wp_create(m["id"], "w") + s.wp_transition(w["id"], "READY") + s.wp_transition(w["id"], "IN_PROGRESS") + s.wp_block(w["id"], "missing asset") + check("wp BLOCKED", s.wp_state(w["id"])["state"] == "BLOCKED") + s.wp_transition(w["id"], "IN_PROGRESS") # BLOCKED->IN_PROGRESS + check("wp resumed IN_PROGRESS", s.wp_state(w["id"])["state"] == "IN_PROGRESS") + + +def test_mission_pause_block_resume(): + print("\n== mission pause/block/resume ==") + s, _ = fresh_store() + m = s.mission_create("mpb") + mid = m["id"] + s.mission_transition(mid, "PLANNING") + s.mission_transition(mid, "READY") + s.mission_transition(mid, "RUNNING") + s.mission_pause(mid) + check("mission PAUSED", s.mission_state(mid)["state"] == "PAUSED") + s.mission_resume(mid) + check("mission RUNNING (resume)", s.mission_state(mid)["state"] == "RUNNING") + s.mission_block(mid) + check("mission BLOCKED", s.mission_state(mid)["state"] == "BLOCKED") + s.mission_resume(mid) # BLOCKED->RUNNING + check("mission RUNNING (block->resume)", s.mission_state(mid)["state"] == "RUNNING") + + +def test_self_dependency_rejected(): + print("\n== self/foreign dependency rejected ==") + s, _ = fresh_store() + m = s.mission_create("sd") + w = s.wp_create(m["id"], "w") + expect_err("self dependency", lambda: s.wp_dependency(m["id"], w["id"], w["id"]), + "INVALID_DEPENDENCY") + m2 = s.mission_create("sd2") + w2 = s.wp_create(m2["id"], "w2") + expect_err("cross-mission dependency", lambda: s.wp_dependency(m["id"], w["id"], w2["id"]), + "INVALID_DEPENDENCY") + + +ALL_TESTS = [ + test_mission_create_read, + test_mission_id_sequence, + test_valid_mission_transitions, + test_invalid_mission_transition, + test_wp_transition_flow, + test_wp_requires_checking_to_done, + test_dependency_gate, + test_mission_complete_requires_done_wps, + test_idempotency, + test_unknown_id_and_state, + test_restart_persistence, + test_fail_closed_no_mutation, + test_state_error_corrupt_state, + test_mirror_isolation, + test_negative_completed_mission_rejects, + test_wp_block_flow, + test_mission_pause_block_resume, + test_self_dependency_rejected, +] + + +def main(): + for fn in ALL_TESTS: + try: + fn() + except Exception as e: # noqa + global FAIL, FAILURES + FAIL += 1 + FAILURES.append(fn.__name__) + print(f" [ERROR] {fn.__name__}: {type(e).__name__}: {e}") + print("\n" + "=" * 60) + print(f"PASS={PASS} FAIL={FAIL}") + if FAIL: + print("FAILURES:", FAILURES) + return 1 + print("ALL TESTS PASSED") + return 0 + + +if __name__ == "__main__": + sys.exit(main())