From 4f8725e2348e7fd6a82f3993ecf75d8481b5eac4 Mon Sep 17 00:00:00 2001
From: Lars
Date: Sat, 11 Jul 2026 07:59:18 +0200
Subject: [PATCH] AP1.4d: Graph Engine mit blocked/ready Read Models.
Migration 020 fuer Kantenarten und Parallelgruppe; steering/graph berechnet Fortschritt; UI zeigt Bereit/Blockiert in Liste und Graph.
Co-authored-by: Cursor
---
.../migrations/020_roadmap_graph_edges.sql | 17 ++
backend/routers/roadmap.py | 21 ++-
backend/services/roadmap.py | 63 +++----
backend/steering/graph/__init__.py | 5 +
backend/steering/graph/roadmap_engine.py | 154 ++++++++++++++++++
backend/tests/test_ap14_roadmap.py | 24 +++
backend/tests/test_ap14d_roadmap_graph.py | 85 ++++++++++
frontend/src/api/roadmap.js | 4 +
.../components/GateDependenciesSection.jsx | 24 ++-
.../src/components/GateGraphStateBadge.jsx | 27 +++
frontend/src/components/GateMapView.jsx | 41 +++--
frontend/src/components/RoadmapItemForm.jsx | 42 ++---
.../src/components/RoadmapPlanSection.jsx | 30 +++-
.../initiative/RoadmapItemDetailPage.jsx | 3 -
frontend/src/styles/components.css | 32 ++++
15 files changed, 488 insertions(+), 84 deletions(-)
create mode 100644 backend/migrations/020_roadmap_graph_edges.sql
create mode 100644 backend/steering/graph/__init__.py
create mode 100644 backend/steering/graph/roadmap_engine.py
create mode 100644 backend/tests/test_ap14d_roadmap_graph.py
create mode 100644 frontend/src/components/GateGraphStateBadge.jsx
diff --git a/backend/migrations/020_roadmap_graph_edges.sql b/backend/migrations/020_roadmap_graph_edges.sql
new file mode 100644
index 0000000..81bbdd3
--- /dev/null
+++ b/backend/migrations/020_roadmap_graph_edges.sql
@@ -0,0 +1,17 @@
+-- AP1.4d: Graph Engine — erweiterte Kantenarten + Parallelgruppe
+
+ALTER TABLE roadmap_item_dependencies
+ DROP CONSTRAINT IF EXISTS roadmap_item_dependencies_dependency_type_check;
+
+ALTER TABLE roadmap_item_dependencies
+ ADD CONSTRAINT roadmap_item_dependencies_dependency_type_check
+ CHECK (dependency_type IN (
+ 'requires', 'blocks', 'related', 'parallel_group', 'optional_branch'
+ ));
+
+ALTER TABLE roadmap_item_dependencies
+ ADD COLUMN IF NOT EXISTS group_key VARCHAR(128) NULL;
+
+CREATE INDEX idx_roadmap_deps_group_key
+ ON roadmap_item_dependencies(tenant_id, group_key)
+ WHERE group_key IS NOT NULL;
diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py
index 1e0ce7b..d9d249f 100644
--- a/backend/routers/roadmap.py
+++ b/backend/routers/roadmap.py
@@ -45,7 +45,10 @@ class RoadmapItemUpdateRequest(BaseModel):
class DependencyCreateRequest(BaseModel):
to_item_id: str
- dependency_type: Literal["requires", "blocks", "related"] = "requires"
+ dependency_type: Literal[
+ "requires", "blocks", "related", "parallel_group", "optional_branch"
+ ] = "requires"
+ group_key: Optional[str] = Field(default=None, max_length=128)
class CriterionCreateRequest(BaseModel):
@@ -143,6 +146,21 @@ def list_initiative_roadmap_criteria_progress(
raise HTTPException(status_code=400, detail=str(exc)) from exc
+@initiative_router.get("/{initiative_id}/roadmap/graph-state")
+def get_initiative_roadmap_graph_state(
+ initiative_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ try:
+ from steering.graph.roadmap_engine import load_initiative_graph_state
+
+ return load_initiative_graph_state(
+ tenant_id=ctx.tenant_id, initiative_id=initiative_id
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
def create_initiative_roadmap_item(
initiative_id: str,
@@ -419,6 +437,7 @@ def add_roadmap_item_dependency(
from_item_id=item_id,
to_item_id=body.to_item_id,
dependency_type=body.dependency_type,
+ group_key=body.group_key,
user_id=ctx.user_id,
)
except ValueError as exc:
diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py
index 7d51f20..9f576f1 100644
--- a/backend/services/roadmap.py
+++ b/backend/services/roadmap.py
@@ -18,14 +18,14 @@ RoadmapItemStatus = Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
]
SequencingMode = Literal["sequential", "parallel", "optional"]
-DependencyType = Literal["requires", "blocks", "related"]
+DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"]
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"})
ROADMAP_ITEM_STATUSES = frozenset(
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
)
SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"})
-DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related"})
+DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related", "parallel_group", "optional_branch"})
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
@@ -457,6 +457,18 @@ def delete_roadmap_item(
return deleted
+def _serialize_dependency(row: dict[str, Any]) -> dict[str, Any]:
+ dep = dict(row)
+ for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
+ if dep.get(key):
+ dep[key] = str(dep[key])
+ if dep.get("created_at"):
+ dep["created_at"] = dep["created_at"].isoformat()
+ if dep.get("group_key") is None:
+ dep.pop("group_key", None)
+ return dep
+
+
def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
@@ -467,22 +479,15 @@ def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
- SELECT id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
+ SELECT id, tenant_id, from_item_id, to_item_id, dependency_type,
+ group_key, created_at
FROM roadmap_item_dependencies
WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s)
ORDER BY created_at ASC
""",
(tenant_id, item_id, item_id),
)
- items = []
- for row in cur.fetchall():
- dep = dict(row)
- for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
- dep[key] = str(dep[key])
- if dep.get("created_at"):
- dep["created_at"] = dep["created_at"].isoformat()
- items.append(dep)
- return items
+ return [_serialize_dependency(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
@@ -499,7 +504,7 @@ def list_dependencies_for_initiative(
cur.execute(
"""
SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id,
- d.dependency_type, d.created_at
+ d.dependency_type, d.group_key, d.created_at
FROM roadmap_item_dependencies d
INNER JOIN roadmap_items fi
ON fi.id = d.from_item_id AND fi.tenant_id = d.tenant_id
@@ -510,15 +515,7 @@ def list_dependencies_for_initiative(
""",
(tenant_id, initiative_id),
)
- items = []
- for row in cur.fetchall():
- dep = dict(row)
- for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
- dep[key] = str(dep[key])
- if dep.get("created_at"):
- dep["created_at"] = dep["created_at"].isoformat()
- items.append(dep)
- return items
+ return [_serialize_dependency(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
@@ -529,6 +526,7 @@ def add_dependency(
from_item_id: str,
to_item_id: str,
dependency_type: DependencyType = "requires",
+ group_key: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
_validate_dependency_type(dependency_type)
@@ -542,6 +540,12 @@ def add_dependency(
if from_item["initiative_id"] != to_item["initiative_id"]:
raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens")
+ normalized_group_key: Optional[str] = None
+ if group_key is not None:
+ normalized_group_key = group_key.strip() or None
+ if dependency_type == "parallel_group" and not normalized_group_key:
+ raise ValueError("group_key ist für parallel_group erforderlich")
+
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
@@ -559,17 +563,15 @@ def add_dependency(
cur.execute(
"""
INSERT INTO roadmap_item_dependencies (
- tenant_id, from_item_id, to_item_id, dependency_type
+ tenant_id, from_item_id, to_item_id, dependency_type, group_key
)
- VALUES (%s, %s, %s, %s)
- RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
+ VALUES (%s, %s, %s, %s, %s)
+ RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type,
+ group_key, created_at
""",
- (tenant_id, from_item_id, to_item_id, dependency_type),
+ (tenant_id, from_item_id, to_item_id, dependency_type, normalized_group_key),
)
- row = dict(cur.fetchone())
- for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
- row[key] = str(row[key])
- row["created_at"] = row["created_at"].isoformat()
+ row = _serialize_dependency(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
@@ -582,6 +584,7 @@ def add_dependency(
"from_item_id": from_item_id,
"to_item_id": to_item_id,
"dependency_type": dependency_type,
+ "group_key": normalized_group_key,
},
)
return row
diff --git a/backend/steering/graph/__init__.py b/backend/steering/graph/__init__.py
new file mode 100644
index 0000000..609d75b
--- /dev/null
+++ b/backend/steering/graph/__init__.py
@@ -0,0 +1,5 @@
+"""Roadmap graph read models (AP1.4d)."""
+
+from steering.graph.roadmap_engine import compute_initiative_graph_state
+
+__all__ = ["compute_initiative_graph_state"]
diff --git a/backend/steering/graph/roadmap_engine.py b/backend/steering/graph/roadmap_engine.py
new file mode 100644
index 0000000..07f1fdd
--- /dev/null
+++ b/backend/steering/graph/roadmap_engine.py
@@ -0,0 +1,154 @@
+"""Roadmap gate graph read models — Plan-Zielzustand, kein Workflow (AP1.4d)."""
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
+ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
+BLOCKING_STATUSES = frozenset({"planned", "active", "at_risk"})
+
+
+def _is_prerequisite_satisfied(status: str) -> bool:
+ return status == "reached"
+
+
+def _fulfillment_ratio(progress: Optional[dict[str, int]]) -> Optional[float]:
+ if not progress:
+ return None
+ total = progress.get("total", 0)
+ if total <= 0:
+ return None
+ closed = progress.get("closed", 0)
+ return round(closed / total, 4)
+
+
+def _sort_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ return sorted(
+ items,
+ key=lambda item: (
+ item.get("sort_order", 0),
+ item.get("title") or "",
+ str(item.get("id", "")),
+ ),
+ )
+
+
+def _sequential_order_blocked_by(
+ item: dict[str, Any],
+ sorted_items: list[dict[str, Any]],
+) -> list[str]:
+ """Fallback wenn keine Kanten: sequenzielle Items nach sort_order."""
+ blocked_by: list[str] = []
+ item_id = str(item["id"])
+ for prev in sorted_items:
+ if str(prev["id"]) == item_id:
+ break
+ if prev.get("sequencing_mode", "sequential") != "sequential":
+ continue
+ if not _is_prerequisite_satisfied(prev.get("status", "planned")):
+ blocked_by.append(str(prev["id"]))
+ return blocked_by
+
+
+def compute_initiative_graph_state(
+ *,
+ items: list[dict[str, Any]],
+ dependencies: list[dict[str, Any]],
+ criteria_progress: Optional[dict[str, dict[str, int]]] = None,
+) -> dict[str, Any]:
+ """
+ Read Model pro Initiative: blocked / ready / fulfillment_ratio.
+
+ Kanten-Semantik (from → to):
+ - requires: from benötigt to (Voraussetzung) als reached
+ - optional_branch: wie requires, blockiert aber nicht (nur Metadaten)
+ - blocks: from blockiert to solange from noch aktiv ist
+ - parallel_group / related: keine Blockade
+ """
+ criteria_progress = criteria_progress or {}
+ sorted_items = _sort_items(items)
+ item_by_id = {str(item["id"]): item for item in items}
+ has_any_deps = len(dependencies) > 0
+
+ blocked_by_map: dict[str, list[str]] = {item_id: [] for item_id in item_by_id}
+
+ for dep in dependencies:
+ dep_type = dep.get("dependency_type") or dep.get("edge_kind") or "requires"
+ from_id = str(dep["from_item_id"])
+ to_id = str(dep["to_item_id"])
+ if from_id not in item_by_id or to_id not in item_by_id:
+ continue
+
+ if dep_type == "requires":
+ prereq = item_by_id[to_id]
+ if not _is_prerequisite_satisfied(prereq.get("status", "planned")):
+ if to_id not in blocked_by_map[from_id]:
+ blocked_by_map[from_id].append(to_id)
+ elif dep_type == "blocks":
+ blocker = item_by_id[from_id]
+ if blocker.get("status", "planned") in BLOCKING_STATUSES:
+ if from_id not in blocked_by_map[to_id]:
+ blocked_by_map[to_id].append(from_id)
+
+ if not has_any_deps:
+ for item in sorted_items:
+ item_id = str(item["id"])
+ if item.get("sequencing_mode", "sequential") == "sequential":
+ for prereq_id in _sequential_order_blocked_by(item, sorted_items):
+ if prereq_id not in blocked_by_map[item_id]:
+ blocked_by_map[item_id].append(prereq_id)
+
+ item_states: dict[str, dict[str, Any]] = {}
+ blocked_items: list[str] = []
+ ready_items: list[str] = []
+
+ for item_id, item in item_by_id.items():
+ status = item.get("status", "planned")
+ blocked_by = blocked_by_map.get(item_id, [])
+ blocked = len(blocked_by) > 0
+ ready = (
+ status in ACTIVE_STATUSES
+ and not blocked
+ )
+
+ ratio = _fulfillment_ratio(criteria_progress.get(item_id))
+ item_states[item_id] = {
+ "blocked": blocked,
+ "ready": ready,
+ "blocked_by": blocked_by,
+ "fulfillment_ratio": ratio,
+ }
+ if blocked and status in ACTIVE_STATUSES:
+ blocked_items.append(item_id)
+ if ready:
+ ready_items.append(item_id)
+
+ return {
+ "items": item_states,
+ "blocked_items": blocked_items,
+ "ready_items": ready_items,
+ }
+
+
+def load_initiative_graph_state(
+ *, tenant_id: str, initiative_id: str
+) -> dict[str, Any]:
+ """Lädt Items, Kanten und Kriterien-Fortschritt und berechnet Graph-State."""
+ from services import roadmap as roadmap_service
+ from services import roadmap_criteria as criteria_service
+
+ items = roadmap_service.list_roadmap_items_for_initiative(
+ tenant_id=tenant_id, initiative_id=initiative_id
+ )
+ dependencies = roadmap_service.list_dependencies_for_initiative(
+ tenant_id=tenant_id, initiative_id=initiative_id
+ )
+ criteria_progress = criteria_service.criteria_progress_for_initiative(
+ tenant_id=tenant_id, initiative_id=initiative_id
+ )
+ return compute_initiative_graph_state(
+ items=items,
+ dependencies=dependencies,
+ criteria_progress=criteria_progress,
+ )
diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py
index 3014ff5..8c42e7a 100644
--- a/backend/tests/test_ap14_roadmap.py
+++ b/backend/tests/test_ap14_roadmap.py
@@ -218,3 +218,27 @@ def test_initiative_roadmap_criteria_progress(client):
body = progress.json()
assert body[item["id"]]["total"] >= 1
assert body[item["id"]]["closed"] == 0
+
+
+def test_initiative_roadmap_graph_state(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A", sort_order=0).json()
+ gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B", sort_order=1).json()
+
+ client.post(
+ f"/api/roadmap-items/{gate_a['id']}/dependencies",
+ json={"to_item_id": gate_b["id"], "dependency_type": "requires"},
+ headers=_auth(token),
+ )
+
+ state = client.get(
+ f"/api/initiatives/{initiative_id}/roadmap/graph-state",
+ headers=_auth(token),
+ )
+ assert state.status_code == 200
+ body = state.json()
+ assert body["items"][gate_a["id"]]["blocked"] is True
+ assert gate_b["id"] in body["items"][gate_a["id"]]["blocked_by"]
+ assert gate_a["id"] not in body["ready_items"]
diff --git a/backend/tests/test_ap14d_roadmap_graph.py b/backend/tests/test_ap14d_roadmap_graph.py
new file mode 100644
index 0000000..e7f05b6
--- /dev/null
+++ b/backend/tests/test_ap14d_roadmap_graph.py
@@ -0,0 +1,85 @@
+"""Unit tests for roadmap graph engine (AP1.4d)."""
+
+from steering.graph.roadmap_engine import compute_initiative_graph_state
+
+
+def _item(item_id: str, status: str = "planned", **kwargs):
+ return {
+ "id": item_id,
+ "status": status,
+ "sort_order": kwargs.get("sort_order", 0),
+ "sequencing_mode": kwargs.get("sequencing_mode", "sequential"),
+ "title": kwargs.get("title", item_id),
+ }
+
+
+def test_requires_blocks_until_prerequisite_reached():
+ items = [_item("a", "planned"), _item("b", "reached")]
+ deps = [
+ {
+ "from_item_id": "a",
+ "to_item_id": "b",
+ "dependency_type": "requires",
+ }
+ ]
+ state = compute_initiative_graph_state(items=items, dependencies=deps)
+ assert state["items"]["a"]["blocked"] is False
+ assert state["items"]["a"]["ready"] is True
+ assert "a" in state["ready_items"]
+
+ items[1]["status"] = "planned"
+ state = compute_initiative_graph_state(items=items, dependencies=deps)
+ assert state["items"]["a"]["blocked"] is True
+ assert state["items"]["a"]["blocked_by"] == ["b"]
+ assert "a" in state["blocked_items"]
+
+
+def test_sequential_fallback_without_edges():
+ items = [
+ _item("first", "planned", sort_order=0),
+ _item("second", "planned", sort_order=1),
+ ]
+ state = compute_initiative_graph_state(items=items, dependencies=[])
+ assert state["items"]["first"]["ready"] is True
+ assert state["items"]["second"]["blocked"] is True
+ assert state["items"]["second"]["blocked_by"] == ["first"]
+
+ items[0]["status"] = "reached"
+ state = compute_initiative_graph_state(items=items, dependencies=[])
+ assert state["items"]["second"]["ready"] is True
+
+
+def test_parallel_sequencing_mode_skips_order_block():
+ items = [
+ _item("first", "planned", sort_order=0),
+ _item("second", "planned", sort_order=1, sequencing_mode="parallel"),
+ ]
+ state = compute_initiative_graph_state(items=items, dependencies=[])
+ assert state["items"]["second"]["ready"] is True
+
+
+def test_blocks_edge():
+ items = [_item("blocker", "active"), _item("blocked", "planned")]
+ deps = [
+ {
+ "from_item_id": "blocker",
+ "to_item_id": "blocked",
+ "dependency_type": "blocks",
+ }
+ ]
+ state = compute_initiative_graph_state(items=items, dependencies=deps)
+ assert state["items"]["blocked"]["blocked"] is True
+ assert state["items"]["blocked"]["blocked_by"] == ["blocker"]
+
+ items[0]["status"] = "reached"
+ state = compute_initiative_graph_state(items=items, dependencies=deps)
+ assert state["items"]["blocked"]["ready"] is True
+
+
+def test_fulfillment_ratio_from_criteria_progress():
+ items = [_item("g", "active")]
+ progress = {"g": {"total": 4, "closed": 2, "open": 2}}
+ state = compute_initiative_graph_state(
+ items=items, dependencies=[], criteria_progress=progress
+ )
+ assert state["items"]["g"]["fulfillment_ratio"] == 0.5
diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js
index 48d9f91..31d7d09 100644
--- a/frontend/src/api/roadmap.js
+++ b/frontend/src/api/roadmap.js
@@ -12,6 +12,10 @@ export function listInitiativeRoadmapCriteriaProgress(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/criteria-progress`)
}
+export function listInitiativeRoadmapGraphState(initiativeId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/roadmap/graph-state`)
+}
+
export function createInitiativeRoadmapItem(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
method: 'POST',
diff --git a/frontend/src/components/GateDependenciesSection.jsx b/frontend/src/components/GateDependenciesSection.jsx
index 2eb679f..98c1f6b 100644
--- a/frontend/src/components/GateDependenciesSection.jsx
+++ b/frontend/src/components/GateDependenciesSection.jsx
@@ -6,6 +6,8 @@ const DEPENDENCY_TYPE_LABELS = {
requires: 'Voraussetzung',
blocks: 'Blockiert',
related: 'Bezug',
+ parallel_group: 'Parallelgruppe',
+ optional_branch: 'Optional',
}
const OUTGOING_PHRASES = {
@@ -56,6 +58,7 @@ function DependencyRow({ dep, itemId, siblingItems, direction, canManage, busy,
{DEPENDENCY_TYPE_LABELS[dep.dependency_type] || dep.dependency_type}
+ {dep.group_key ? ` · Gruppe ${dep.group_key}` : ''}
{canManage && (
@@ -92,11 +95,17 @@ export function GateDependenciesSection({
const form = event.target
const toItemId = form.to_item_id.value
const dependencyType = form.dependency_type.value
+ const groupKey = form.group_key?.value?.trim()
if (!toItemId) return
- await onAdd({
+ const body = {
to_item_id: toItemId,
dependency_type: dependencyType,
- })
+ }
+ if (dependencyType === 'parallel_group') {
+ if (!groupKey) return
+ body.group_key = groupKey
+ }
+ await onAdd(body)
form.reset()
}
@@ -169,9 +178,20 @@ export function GateDependenciesSection({
+
)}
-
-
-
-
+
+
+ Reihenfolge und Parallelität steuerst du über Kanten auf der Gate-Detailseite — nicht mehr
+ über ein Abfolge-Feld.
+
{mode === 'edit' && (