Kairo-Jinkendo/backend/steering/graph/execution_engine.py
Lars 1166a0c7f1
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 2m20s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 14s
AP1.16a-b: Execution-Graph für Arbeitspaket-Abhängigkeiten.
Migration 023, Engine, API und ADP für Durchführungsplan getrennt vom Gate-Graph; Docs und Sprint-Assignment synchronisiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:38:01 +02:00

247 lines
7.9 KiB
Python

"""Execution plan read models — AP1.16b (Action dependency graph)."""
from __future__ import annotations
from typing import Any, Optional
DONE_STATUSES = frozenset({"done"})
OPEN_STATUSES = frozenset(
{"open", "ready", "in_progress", "blocked", "review_required"}
)
TERMINAL_SKIP = frozenset({"done", "discarded"})
def _is_predecessor_satisfied(status: str) -> bool:
return status in DONE_STATUSES
def _sort_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(
actions,
key=lambda action: (
action.get("sort_order", 0),
action.get("title") or "",
str(action.get("id", "")),
),
)
def _sequential_order_blocked_by(
action: dict[str, Any],
sorted_actions: list[dict[str, Any]],
) -> list[str]:
"""Fallback ohne Kanten: sequenzielle Actions nach sort_order."""
blocked_by: list[str] = []
action_id = str(action["id"])
for prev in sorted_actions:
if str(prev["id"]) == action_id:
break
if not _is_predecessor_satisfied(prev.get("status", "open")):
blocked_by.append(str(prev["id"]))
return blocked_by
def _compute_critical_path(
*,
action_by_id: dict[str, dict[str, Any]],
requires_predecessors: dict[str, list[str]],
) -> list[str]:
"""Längste Kette über requires-Kanten (Memoization)."""
memo: dict[str, list[str]] = {}
def chain_for(action_id: str) -> list[str]:
if action_id in memo:
return memo[action_id]
preds = requires_predecessors.get(action_id, [])
if not preds:
memo[action_id] = [action_id]
return memo[action_id]
best: list[str] = []
for pred_id in preds:
if pred_id not in action_by_id:
continue
upstream = chain_for(pred_id)
if len(upstream) > len(best):
best = upstream
memo[action_id] = best + [action_id]
return memo[action_id]
longest: list[str] = []
for action_id in action_by_id:
candidate = chain_for(action_id)
if len(candidate) > len(longest):
longest = candidate
return longest
def compute_planning_debt(
*,
actions: list[dict[str, Any]],
roadmap_items: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Aktive Gates ohne verknüpfte Actions → Planning Debt (AP1.16d light)."""
active_gates = [
item
for item in roadmap_items
if item.get("status") == "active"
]
if not active_gates:
return []
actions_by_gate: dict[str, int] = {}
for action in actions:
gate_id = action.get("roadmap_item_id")
if not gate_id:
continue
gid = str(gate_id)
actions_by_gate[gid] = actions_by_gate.get(gid, 0) + 1
debts: list[dict[str, Any]] = []
for gate in active_gates:
gid = str(gate["id"])
if actions_by_gate.get(gid, 0) == 0:
debts.append(
{
"kind": "missing_execution_plan",
"roadmap_item_id": gid,
"title": gate.get("title"),
"message": "Aktiver Zielzustand ohne Durchführungsplan",
}
)
return debts
def compute_execution_graph_state(
*,
actions: list[dict[str, Any]],
dependencies: list[dict[str, Any]],
scope_roadmap_item_id: Optional[str] = None,
) -> dict[str, Any]:
"""
Read Model pro Initiative (optional Gate-Scope).
DB-Kante: predecessor → successor
- requires: successor blocked bis predecessor done
- blocks: successor blocked solange predecessor offen
- relates: keine Blockade
"""
if scope_roadmap_item_id:
scope = str(scope_roadmap_item_id)
actions = [
a
for a in actions
if a.get("roadmap_item_id") and str(a["roadmap_item_id"]) == scope
]
action_ids = {str(a["id"]) for a in actions}
dependencies = [
dep
for dep in dependencies
if str(dep["predecessor_action_id"]) in action_ids
and str(dep["successor_action_id"]) in action_ids
]
sorted_actions = _sort_actions(actions)
action_by_id = {str(action["id"]): action for action in actions}
has_any_deps = len(dependencies) > 0
blocked_by_map: dict[str, list[str]] = {
action_id: [] for action_id in action_by_id
}
requires_predecessors: dict[str, list[str]] = {
action_id: [] for action_id in action_by_id
}
for dep in dependencies:
kind = dep.get("dependency_kind") or "requires"
pred_id = str(dep["predecessor_action_id"])
succ_id = str(dep["successor_action_id"])
if pred_id not in action_by_id or succ_id not in action_by_id:
continue
if kind == "requires":
requires_predecessors.setdefault(succ_id, []).append(pred_id)
predecessor = action_by_id[pred_id]
if not _is_predecessor_satisfied(predecessor.get("status", "open")):
if pred_id not in blocked_by_map[succ_id]:
blocked_by_map[succ_id].append(pred_id)
elif kind == "blocks":
blocker = action_by_id[pred_id]
if blocker.get("status", "open") in OPEN_STATUSES:
if pred_id not in blocked_by_map[succ_id]:
blocked_by_map[succ_id].append(pred_id)
# relates: no block
if not has_any_deps:
for action in sorted_actions:
action_id = str(action["id"])
for prereq_id in _sequential_order_blocked_by(action, sorted_actions):
if prereq_id not in blocked_by_map[action_id]:
blocked_by_map[action_id].append(prereq_id)
if prereq_id not in requires_predecessors.get(action_id, []):
requires_predecessors.setdefault(action_id, []).append(prereq_id)
action_states: dict[str, dict[str, Any]] = {}
blocked_actions: list[str] = []
ready_actions: list[str] = []
for action_id, action in action_by_id.items():
status = action.get("status", "open")
blocked_by = blocked_by_map.get(action_id, [])
blocked = len(blocked_by) > 0 and status not in TERMINAL_SKIP
ready = status in OPEN_STATUSES and not blocked
action_states[action_id] = {
"status": status,
"action_kind": action.get("action_kind", "delivery"),
"sort_order": action.get("sort_order", 0),
"roadmap_item_id": (
str(action["roadmap_item_id"]) if action.get("roadmap_item_id") else None
),
"blocked": blocked,
"ready": ready,
"blocked_by": blocked_by,
}
if blocked:
blocked_actions.append(action_id)
if ready:
ready_actions.append(action_id)
critical_path = _compute_critical_path(
action_by_id=action_by_id,
requires_predecessors=requires_predecessors,
)
result: dict[str, Any] = {
"items": action_states,
"blocked_actions": blocked_actions,
"ready_actions": ready_actions,
"critical_path": critical_path,
"scope_roadmap_item_id": scope_roadmap_item_id,
}
return result
def load_initiative_execution_graph_state(
*,
tenant_id: str,
initiative_id: str,
scope_roadmap_item_id: Optional[str] = None,
) -> dict[str, Any]:
"""Lädt Actions, Kanten und optional Planning Debt."""
from services import actions as action_service
from services import execution_plan as execution_plan_service
actions = action_service.list_actions_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
dependencies = execution_plan_service.list_dependencies_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
return compute_execution_graph_state(
actions=actions,
dependencies=dependencies,
scope_roadmap_item_id=scope_roadmap_item_id,
)