Coverage for src / orchestration / factory.py: 0%
106 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-04 04:43 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-04 04:43 +0000
1"""Factory function for MalaOrchestrator initialization.
3This module encapsulates the ~250-line __init__ logic into a clean factory
4pattern with explicit configuration and dependency dataclasses.
6Design principles:
7- OrchestratorConfig: All scalar configuration (timeouts, flags, limits)
8- OrchestratorDependencies: All protocol implementations (DI for testability)
9- create_orchestrator(): Factory function encapsulating initialization logic
11Usage:
12 # Simple usage with defaults
13 config = OrchestratorConfig(repo_path=Path("."))
14 orchestrator = create_orchestrator(config)
16 # With explicit MalaConfig for API keys
17 mala_config = MalaConfig.from_env()
18 orchestrator = create_orchestrator(config, mala_config=mala_config)
20 # With custom dependencies for testing
21 deps = OrchestratorDependencies(
22 issue_provider=mock_beads,
23 code_reviewer=mock_reviewer,
24 )
25 orchestrator = create_orchestrator(config, deps=deps)
26"""
28from __future__ import annotations
30import os
31import shutil
32from typing import TYPE_CHECKING, cast
34from src.infra.tools.env import USER_CONFIG_DIR
36# Import shared types from types module (breaks circular import)
37from .types import (
38 DEFAULT_AGENT_TIMEOUT_MINUTES,
39 OrchestratorConfig,
40 OrchestratorDependencies,
41 _DerivedConfig,
42)
44# Re-export for backwards compatibility
45__all__ = [
46 "DEFAULT_AGENT_TIMEOUT_MINUTES",
47 "OrchestratorConfig",
48 "OrchestratorDependencies",
49 "create_orchestrator",
50]
52import logging
54if TYPE_CHECKING:
55 from src.core.protocols import (
56 CodeReviewer,
57 EpicVerificationModel,
58 GateChecker,
59 IssueProvider,
60 LogProvider,
61 )
62 from src.infra.epic_verifier import EpicVerifier
63 from src.infra.io.config import MalaConfig
64 from src.core.protocols import MalaEventSink
65 from src.infra.telemetry import TelemetryProvider
67 from .orchestrator import MalaOrchestrator
69logger = logging.getLogger(__name__)
72def _derive_config(
73 config: OrchestratorConfig,
74 mala_config: MalaConfig,
75) -> _DerivedConfig:
76 """Derive computed configuration values from config sources.
78 Config precedence: OrchestratorConfig > MalaConfig > defaults
80 Args:
81 config: User-provided orchestrator configuration.
82 mala_config: MalaConfig with API keys and feature flags.
84 Returns:
85 _DerivedConfig with computed values.
86 """
87 # Compute timeout - match legacy behavior where 0 is treated as "use default"
88 # (truthiness check: `if timeout_minutes` treats 0 as falsy)
89 effective_timeout = (
90 config.timeout_minutes
91 if config.timeout_minutes
92 else DEFAULT_AGENT_TIMEOUT_MINUTES
93 )
94 timeout_seconds = effective_timeout * 60
96 # Derive feature flags from mala_config if not explicitly set
97 if config.braintrust_enabled is not None:
98 braintrust_enabled = config.braintrust_enabled
99 else:
100 braintrust_enabled = mala_config.braintrust_enabled
102 # Build disabled validations set
103 disabled_validations = (
104 set(config.disable_validations) if config.disable_validations else set()
105 )
107 # Compute braintrust disabled reason
108 braintrust_disabled_reason: str | None = None
109 if not braintrust_enabled:
110 if config.cli_args and config.cli_args.get("no_braintrust"):
111 braintrust_disabled_reason = "--no-braintrust"
112 elif not mala_config.braintrust_api_key:
113 braintrust_disabled_reason = (
114 f"add BRAINTRUST_API_KEY to {USER_CONFIG_DIR}/.env"
115 )
116 else:
117 braintrust_disabled_reason = "disabled by config"
119 logger.debug(
120 "Derived config: braintrust=%s timeout=%ds",
121 braintrust_enabled,
122 timeout_seconds,
123 )
124 return _DerivedConfig(
125 timeout_seconds=timeout_seconds,
126 braintrust_enabled=braintrust_enabled,
127 disabled_validations=disabled_validations,
128 braintrust_disabled_reason=braintrust_disabled_reason,
129 )
132def _check_review_availability(
133 mala_config: MalaConfig,
134 disabled_validations: set[str],
135) -> str | None:
136 """Check if code review is available.
138 Returns the reason review is disabled, or None if available.
139 """
140 if "review" in disabled_validations:
141 return None # Explicitly disabled, no warning needed
143 review_gate_path = (
144 mala_config.cerberus_bin_path / "review-gate"
145 if mala_config.cerberus_bin_path
146 else None
147 )
149 if review_gate_path is None:
150 # No explicit bin_path - check PATH (respecting cerberus_env if set)
151 cerberus_env_dict = dict(mala_config.cerberus_env)
152 if "PATH" in cerberus_env_dict:
153 effective_path = (
154 cerberus_env_dict["PATH"] + os.pathsep + os.environ.get("PATH", "")
155 )
156 else:
157 effective_path = os.environ.get("PATH", "")
158 if shutil.which("review-gate", path=effective_path) is None:
159 reason = "cerberus plugin not detected (review-gate unavailable)"
160 logger.info("Review disabled: reason=%s", reason)
161 return reason
162 elif not review_gate_path.exists():
163 reason = f"review-gate missing at {review_gate_path}"
164 logger.info("Review disabled: reason=%s", reason)
165 return reason
166 elif not review_gate_path.is_file():
167 reason = f"review-gate path is not a file: {review_gate_path}"
168 logger.info("Review disabled: reason=%s", reason)
169 return reason
170 elif not os.access(review_gate_path, os.X_OK):
171 reason = f"review-gate not executable at {review_gate_path}"
172 logger.info("Review disabled: reason=%s", reason)
173 return reason
175 return None
178def _build_dependencies(
179 config: OrchestratorConfig,
180 mala_config: MalaConfig,
181 derived: _DerivedConfig,
182 deps: OrchestratorDependencies | None,
183) -> tuple[
184 IssueProvider,
185 CodeReviewer,
186 GateChecker,
187 LogProvider,
188 TelemetryProvider,
189 MalaEventSink,
190 EpicVerifier | None,
191]:
192 """Build all dependencies, using provided ones or creating defaults.
194 Args:
195 config: Orchestrator configuration.
196 mala_config: MalaConfig with API keys.
197 derived: Derived configuration values.
198 deps: Optional pre-built dependencies.
200 Returns:
201 Tuple of all required dependencies.
202 """
203 from src.core.models import RetryConfig
204 from src.domain.quality_gate import QualityGate
205 from src.infra.clients.beads_client import BeadsClient
206 from src.infra.clients.braintrust_integration import BraintrustProvider
207 from src.infra.clients.cerberus_review import DefaultReviewer
208 from src.infra.epic_verifier import ClaudeEpicVerificationModel, EpicVerifier
209 from src.infra.io.console_sink import ConsoleEventSink
210 from src.infra.io.session_log_parser import FileSystemLogProvider
211 from src.infra.telemetry import NullTelemetryProvider
212 from src.infra.tools.command_runner import CommandRunner
214 # Get resolved path
215 repo_path = config.repo_path.resolve()
217 # Command runner (shared by components that need it)
218 command_runner = CommandRunner(cwd=repo_path)
220 # Event sink (needed for log_warning in BeadsClient)
221 if deps is not None and deps.event_sink is not None:
222 event_sink = deps.event_sink
223 else:
224 event_sink = ConsoleEventSink()
226 # Log provider
227 log_provider: LogProvider
228 if deps is not None and deps.log_provider is not None:
229 log_provider = deps.log_provider
230 else:
231 log_provider = cast("LogProvider", FileSystemLogProvider())
233 # Gate checker (needs log_provider and command_runner)
234 gate_checker: GateChecker
235 if deps is not None and deps.gate_checker is not None:
236 gate_checker = deps.gate_checker
237 else:
238 gate_checker = cast(
239 "GateChecker",
240 QualityGate(
241 repo_path, log_provider=log_provider, command_runner=command_runner
242 ),
243 )
245 # Issue provider (needs event_sink for warnings)
246 issue_provider: IssueProvider
247 if deps is not None and deps.issue_provider is not None:
248 issue_provider = deps.issue_provider
249 else:
251 def log_warning(msg: str) -> None:
252 event_sink.on_warning(msg)
254 beads_client = BeadsClient(repo_path, log_warning=log_warning)
255 # BeadsClient implements IssueProvider protocol
256 issue_provider = beads_client # type: ignore[assignment]
258 # Epic verifier (only when using real BeadsClient - either created or injected)
259 epic_verifier: EpicVerifier | None = None
260 if isinstance(issue_provider, BeadsClient):
261 verification_model = ClaudeEpicVerificationModel(
262 timeout_ms=derived.timeout_seconds * 1000,
263 retry_config=RetryConfig(),
264 repo_path=repo_path,
265 )
266 from src.infra.tools.locking import LockManager
268 epic_verifier = EpicVerifier(
269 beads=issue_provider,
270 model=cast("EpicVerificationModel", verification_model),
271 repo_path=repo_path,
272 command_runner=command_runner,
273 event_sink=event_sink,
274 lock_manager=LockManager(),
275 )
277 # Code reviewer
278 code_reviewer: CodeReviewer
279 if deps is not None and deps.code_reviewer is not None:
280 code_reviewer = deps.code_reviewer
281 else:
282 code_reviewer = cast(
283 "CodeReviewer",
284 DefaultReviewer(
285 repo_path=repo_path,
286 bin_path=mala_config.cerberus_bin_path,
287 spawn_args=mala_config.cerberus_spawn_args,
288 wait_args=mala_config.cerberus_wait_args,
289 env=dict(mala_config.cerberus_env),
290 event_sink=event_sink,
291 ),
292 )
294 # Telemetry provider
295 telemetry_provider: TelemetryProvider
296 if deps is not None and deps.telemetry_provider is not None:
297 telemetry_provider = deps.telemetry_provider
298 elif derived.braintrust_enabled:
299 telemetry_provider = cast("TelemetryProvider", BraintrustProvider())
300 else:
301 telemetry_provider = cast("TelemetryProvider", NullTelemetryProvider())
303 return (
304 issue_provider,
305 code_reviewer,
306 gate_checker,
307 log_provider,
308 telemetry_provider,
309 event_sink,
310 epic_verifier,
311 )
314def create_orchestrator(
315 config: OrchestratorConfig,
316 *,
317 mala_config: MalaConfig | None = None,
318 deps: OrchestratorDependencies | None = None,
319) -> MalaOrchestrator:
320 """Create a MalaOrchestrator with the given configuration.
322 This factory function encapsulates all initialization logic that was
323 previously in MalaOrchestrator.__init__. It:
324 1. Derives computed configuration from config sources
325 2. Checks review availability
326 3. Builds dependencies (using provided or creating defaults)
327 4. Constructs and returns the orchestrator
329 Config precedence: config > mala_config > defaults
331 Args:
332 config: OrchestratorConfig with all scalar configuration.
333 mala_config: Optional MalaConfig for API keys and feature flags.
334 If None, loads from environment.
335 deps: Optional OrchestratorDependencies for custom implementations.
336 If None, creates default implementations.
338 Returns:
339 Configured MalaOrchestrator ready for run().
341 Example:
342 # Simple usage
343 config = OrchestratorConfig(repo_path=Path("."))
344 orchestrator = create_orchestrator(config)
345 success, total = await orchestrator.run()
347 # With custom dependencies for testing
348 deps = OrchestratorDependencies(
349 issue_provider=mock_beads,
350 gate_checker=mock_gate,
351 )
352 orchestrator = create_orchestrator(config, deps=deps)
353 """
354 from src.infra.io.config import MalaConfig
356 from .orchestrator import MalaOrchestrator
358 # Load MalaConfig if not provided
359 if mala_config is None:
360 mala_config = MalaConfig.from_env(validate=False)
362 # Derive computed configuration
363 derived = _derive_config(config, mala_config)
365 # Check review availability and update disabled_validations
366 review_disabled_reason = _check_review_availability(
367 mala_config, derived.disabled_validations
368 )
369 if review_disabled_reason:
370 derived.disabled_validations.add("review")
371 derived.review_disabled_reason = review_disabled_reason
373 # Build dependencies
374 (
375 issue_provider,
376 code_reviewer,
377 gate_checker,
378 log_provider,
379 telemetry_provider,
380 event_sink,
381 epic_verifier,
382 ) = _build_dependencies(config, mala_config, derived, deps)
384 # Create orchestrator using internal constructor
385 logger.info(
386 "Orchestrator created: max_agents=%d timeout=%ds",
387 config.max_agents or 0,
388 derived.timeout_seconds,
389 )
390 return MalaOrchestrator(
391 _config=config,
392 _mala_config=mala_config,
393 _derived=derived,
394 _issue_provider=issue_provider,
395 _code_reviewer=code_reviewer,
396 _gate_checker=gate_checker,
397 _log_provider=log_provider,
398 _telemetry_provider=telemetry_provider,
399 _event_sink=event_sink,
400 _epic_verifier=epic_verifier,
401 )