All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m18s
Test Suite / lint-backend (push) Successful in 2s
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 12s
Attention-Regeln planning_debt/execution_waiting; Portfolio-Kacheln zeigen Steuerungssignale. Co-authored-by: Cursor <cursoragent@cursor.com>
318 lines
10 KiB
Python
318 lines
10 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 planning_debt_to_attention_items(
|
|
*,
|
|
initiative_id: str,
|
|
debts: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Map Planning Debt read model → Attention Items (AP1.16d)."""
|
|
items: list[dict[str, Any]] = []
|
|
for debt in debts:
|
|
gate_id = str(debt["roadmap_item_id"])
|
|
items.append(
|
|
{
|
|
"kind": "planning_debt",
|
|
"severity": "warning",
|
|
"title": debt.get("title") or "Zielzustand",
|
|
"summary": debt.get("message") or "Durchführungsplan fehlt",
|
|
"scope_type": "milestone",
|
|
"scope_id": gate_id,
|
|
"initiative_id": initiative_id,
|
|
"action_id": None,
|
|
"blocker_id": None,
|
|
"milestone_id": gate_id,
|
|
"reason_code": "planning_debt",
|
|
"data_source": "execution_graph",
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def execution_waiting_to_attention_items(
|
|
*,
|
|
initiative_id: str,
|
|
actions: list[dict[str, Any]],
|
|
graph_state: dict[str, Any],
|
|
) -> list[dict[str, Any]]:
|
|
"""Actions blocked by Execution-Graph (nicht Status=blocked) → Attention."""
|
|
action_by_id = {str(action["id"]): action for action in actions}
|
|
items: list[dict[str, Any]] = []
|
|
for action_id in graph_state.get("blocked_actions", []):
|
|
action = action_by_id.get(str(action_id))
|
|
if not action:
|
|
continue
|
|
if action.get("status") in ("done", "discarded", "blocked"):
|
|
continue
|
|
meta = graph_state.get("items", {}).get(str(action_id), {})
|
|
blocked_by = meta.get("blocked_by") or []
|
|
summary = (
|
|
"Wartet auf Vorgänger-Arbeitspaket"
|
|
if len(blocked_by) == 1
|
|
else f"Wartet auf {len(blocked_by)} Vorgänger"
|
|
)
|
|
items.append(
|
|
{
|
|
"kind": "execution_waiting",
|
|
"severity": "info",
|
|
"title": action.get("title") or "Arbeitspaket",
|
|
"summary": summary,
|
|
"scope_type": "action",
|
|
"scope_id": str(action_id),
|
|
"initiative_id": initiative_id,
|
|
"action_id": str(action_id),
|
|
"blocker_id": None,
|
|
"milestone_id": (
|
|
str(action["roadmap_item_id"]) if action.get("roadmap_item_id") else None
|
|
),
|
|
"reason_code": "execution_waiting",
|
|
"data_source": "execution_graph",
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
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,
|
|
)
|