Coverage for src/keel/orchestrator.py: 100%
78 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
1"""The pure run planner: lay the project's gates/extensions onto the backbone.
3``build_plan`` is deterministic and I/O-free — it answers "what would run, in what
4order, for this project?" by mapping the planned gates onto their backbone steps.
5This is exactly what a dry-run / ``keel plan`` shows, and what config-injection
6tests assert against (the right project values appear, no foreign ones leak).
8Actually *executing* the plan (git, gh, agent dispatch) is the thin I/O layer; the
9ordering and composition live here, so they stay testable and reproducible.
10"""
12from __future__ import annotations
14from dataclasses import dataclass
16from . import gates, model
17from .config import ProjectConfig
18from .extensions import Extension
21@dataclass(frozen=True)
22class PlanHook:
23 """One resolved extension hook rendered in step order."""
25 slot: str
26 id: str
27 kind: str
28 on_fail: str
29 execution_mode: str
30 adapter_required: bool
31 required_capabilities: tuple[str, ...] = ()
32 optional_capabilities: tuple[str, ...] = ()
35@dataclass(frozen=True)
36class PlanSlot:
37 """One add-only extension slot exposed by a backbone step."""
39 name: str
40 step_id: str
41 execution_mode: str
42 may_block: bool
43 adapter_required: bool
44 hook_count: int
47@dataclass(frozen=True)
48class PlanItem:
49 """One backbone step plus the gate ids that execute at it."""
51 step_id: str
52 step_name: str
53 agentic: bool
54 gates: tuple[str, ...] = ()
55 extension_slots: tuple[PlanSlot, ...] = ()
56 hooks: tuple[PlanHook, ...] = ()
59def build_plan(
60 config: ProjectConfig,
61 loaded: dict[str, list[Extension]],
62 *,
63 implement_mode: str | None = None,
64) -> tuple[PlanItem, ...]:
65 """Map the planned gates onto the fixed backbone (deterministic).
67 ``implement_mode`` is threaded to :func:`keel.gates.plan_gates` so a run selecting the
68 test-first s4 profile with ``--tdd`` sees its ``tdd-order`` gate in the rendered plan,
69 rather than a plan that disagrees with the gates the same run will execute.
70 """
71 specs = gates.plan_gates(config, loaded, implement_mode=implement_mode)
72 test_gates = tuple(s.id for s in specs if s.phase == "test")
73 pre_merge_gates = tuple(s.id for s in specs if s.phase == "pre-merge")
75 items: list[PlanItem] = []
76 for step in model.BACKBONE:
77 if step.name == "guard":
78 step_gates = tuple(s.id for s in specs if s.phase == "guard")
79 elif step.name == "test":
80 step_gates = test_gates
81 elif step.name == "merge":
82 step_gates = pre_merge_gates
83 else:
84 step_gates = ()
85 items.append(
86 PlanItem(
87 step.id,
88 step.name,
89 step.agentic,
90 step_gates,
91 _slots_for_step(step.id, loaded),
92 _hooks_for_step(step.id, loaded),
93 )
94 )
95 return tuple(items)
98def render_plan(config: ProjectConfig, plan: tuple[PlanItem, ...]) -> str:
99 """Render a plan as a stable, human-readable tree (used by dry-run / CLI)."""
100 repo = config.repo or "(repo)"
101 lines = [
102 f"keel plan — {repo}",
103 f" base_branch: {config.base_branch} core_version: {config.core_version}",
104 " backbone:",
105 ]
106 for item in plan:
107 marker = " [agent]" if item.agentic else ""
108 lines.append(f" {item.step_id:>3} {item.step_name}{marker}")
109 for gate in item.gates:
110 # The one built-in gate whose command can be absent (#1328): say so here,
111 # where an operator reads the plan, rather than first in a blocked run.
112 unset = gate == "build" and not config.knobs.build_gate_cmd
113 note = " (not configured: set knobs.build_gate_cmd)" if unset else ""
114 lines.append(f" - gate: {gate}{note}")
115 for hook in item.hooks:
116 caps = _capability_summary(hook)
117 adapter = " adapter-required" if hook.adapter_required else ""
118 lines.append(
119 f" - hook: {hook.id} [{hook.slot}; {hook.kind}; "
120 f"on_fail={hook.on_fail}; mode={hook.execution_mode}{adapter}{caps}]"
121 )
122 return "\n".join(lines)
125def plan_as_dict(plan: tuple[PlanItem, ...]) -> list[dict]:
126 """Render a plan as plain data for JSON output."""
127 return [
128 {
129 "step_id": item.step_id,
130 "step_name": item.step_name,
131 "agentic": item.agentic,
132 "gates": list(item.gates),
133 "extension_slots": [
134 {
135 "name": slot.name,
136 "step_id": slot.step_id,
137 "execution_mode": slot.execution_mode,
138 "may_block": slot.may_block,
139 "adapter_required": slot.adapter_required,
140 "hook_count": slot.hook_count,
141 "customization": "add-only",
142 "failure_mode": "blocking-capable" if slot.may_block else "fail-soft",
143 }
144 for slot in item.extension_slots
145 ],
146 "hooks": [
147 {
148 "slot": hook.slot,
149 "id": hook.id,
150 "kind": hook.kind,
151 "on_fail": hook.on_fail,
152 "execution_mode": hook.execution_mode,
153 "adapter_required": hook.adapter_required,
154 "required_capabilities": list(hook.required_capabilities),
155 "optional_capabilities": list(hook.optional_capabilities),
156 }
157 for hook in item.hooks
158 ],
159 }
160 for item in plan
161 ]
164def _slots_for_step(step_id: str, loaded: dict[str, list[Extension]]) -> tuple[PlanSlot, ...]:
165 return tuple(
166 PlanSlot(
167 name=slot.name,
168 step_id=slot.step_id,
169 execution_mode=slot.execution_mode,
170 may_block=slot.may_block,
171 adapter_required=slot.adapter_required,
172 hook_count=len(loaded.get(slot.name, [])),
173 )
174 for slot in model.slots_for_step(step_id)
175 )
178def _hooks_for_step(step_id: str, loaded: dict[str, list[Extension]]) -> tuple[PlanHook, ...]:
179 hooks: list[PlanHook] = []
180 for slot in model.slots_for_step(step_id):
181 for ext in loaded.get(slot.name, []):
182 hooks.append(
183 PlanHook(
184 slot=slot.name,
185 id=ext.id,
186 kind=ext.kind,
187 on_fail=ext.on_fail,
188 execution_mode=ext.mode,
189 adapter_required=slot.adapter_required or ext.kind == "agentic",
190 required_capabilities=ext.required_capabilities,
191 optional_capabilities=ext.optional_capabilities,
192 )
193 )
194 return tuple(hooks)
197def _capability_summary(hook: PlanHook) -> str:
198 parts = []
199 if hook.required_capabilities:
200 parts.append("required=" + ",".join(hook.required_capabilities))
201 if hook.optional_capabilities:
202 parts.append("optional=" + ",".join(hook.optional_capabilities))
203 return "; " + "; ".join(parts) if parts else ""