"""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, )