feat(a2): persistent mission state & state machine
RED QUEEN BUILD PHASE A2: Mission-State- und Work-Package-State-Machine. Minimaler Missions-Metadata-Layer (missions.db) auf nativer Hermes-Kanban-Basis (KanbanMirror best effort). Deterministische Validierung, Idempotenz, STATE_ERROR fail-closed, Restart-Persistenz. 61 deterministische Tests PASS. Keine autonome Orchestrierung/Loops/Cron.
This commit is contained in:
parent
3d738e6015
commit
dbbbb1dddc
6 changed files with 1332 additions and 0 deletions
4
a2/.gitignore
vendored
Normal file
4
a2/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
*.tmp
|
||||||
33
a2/MISSION.md.template
Normal file
33
a2/MISSION.md.template
Normal file
|
|
@ -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.)_
|
||||||
624
a2/rq_mission.py
Normal file
624
a2/rq_mission.py
Normal file
|
|
@ -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 `<mission_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: <mission_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()
|
||||||
154
a2/rq_mission_cli.py
Normal file
154
a2/rq_mission_cli.py
Normal file
|
|
@ -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())
|
||||||
121
a2/rq_state_machine.py
Normal file
121
a2/rq_state_machine.py
Normal file
|
|
@ -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
|
||||||
396
a2/test_a2.py
Normal file
396
a2/test_a2.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Reference in a new issue