Coverage for src/orchestration/factory.py: 0%

1"""Factory function for MalaOrchestrator initialization.

2

3 This module encapsulates the ~250-line __init__ logic into a clean factory

4 pattern with explicit configuration and dependency dataclasses.

5

6 Design 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

10

11 Usage:

12 # Simple usage with defaults

13 config = OrchestratorConfig(repo_path=Path("."))

14 orchestrator = create_orchestrator(config)

15

16 # With explicit MalaConfig for API keys

17 mala_config = MalaConfig.from_env()

18 orchestrator = create_orchestrator(config, mala_config=mala_config)

19

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

27

28 from __future__ import annotations

29

30 import os

31 import shutil

32 from typing import TYPE_CHECKING, cast

33

34 from src.infra.tools.env import USER_CONFIG_DIR

35

36# Import shared types from types module (breaks circular import)

37 from .types import (

38 DEFAULT_AGENT_TIMEOUT_MINUTES,

39 OrchestratorConfig,

40 OrchestratorDependencies,

41 _DerivedConfig,

42)

43

44# Re-export for backwards compatibility

45 __all__ = [

46 "DEFAULT_AGENT_TIMEOUT_MINUTES",

47 "OrchestratorConfig",

48 "OrchestratorDependencies",

49 "create_orchestrator",

50]

51

52 import logging

53

54 if 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

66

67 from .orchestrator import MalaOrchestrator

68

69 logger = logging.getLogger(__name__)

70

71

72 def _derive_config(

73 config: OrchestratorConfig,

74 mala_config: MalaConfig,

75) -> _DerivedConfig:

76 """Derive computed configuration values from config sources.

77

78 Config precedence: OrchestratorConfig > MalaConfig > defaults

79

80 Args:

81 config: User-provided orchestrator configuration.

82 mala_config: MalaConfig with API keys and feature flags.

83

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

95

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

101

102 # Build disabled validations set

103 disabled_validations = (

104 set(config.disable_validations) if config.disable_validations else set()

105 )

106

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"

118

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 )

130

131

132 def _check_review_availability(

133 mala_config: MalaConfig,

134 disabled_validations: set[str],

135) -> str | None:

136 """Check if code review is available.

137

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

142

143 review_gate_path = (

144 mala_config.cerberus_bin_path / "review-gate"

145 if mala_config.cerberus_bin_path

146 else None

147 )

148

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

174

175 return None

176

177

178 def _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.

193

194 Args:

195 config: Orchestrator configuration.

196 mala_config: MalaConfig with API keys.

197 derived: Derived configuration values.

198 deps: Optional pre-built dependencies.

199

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

213

214 # Get resolved path

215 repo_path = config.repo_path.resolve()

216

217 # Command runner (shared by components that need it)

218 command_runner = CommandRunner(cwd=repo_path)

219

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()

225

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())

232

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 )

244

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:

250

251 def log_warning(msg: str) -> None:

252 event_sink.on_warning(msg)

253

254 beads_client = BeadsClient(repo_path, log_warning=log_warning)

255 # BeadsClient implements IssueProvider protocol

256 issue_provider = beads_client # type: ignore[assignment]

257

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

267

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 )

276

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 )

293

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())

302

303 return (

304 issue_provider,

305 code_reviewer,

306 gate_checker,

307 log_provider,

308 telemetry_provider,

309 event_sink,

310 epic_verifier,

311 )

312

313

314 def 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.

321

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

328

329 Config precedence: config > mala_config > defaults

330

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.

337

338 Returns:

339 Configured MalaOrchestrator ready for run().

340

341 Example:

342 # Simple usage

343 config = OrchestratorConfig(repo_path=Path("."))

344 orchestrator = create_orchestrator(config)

345 success, total = await orchestrator.run()

346

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

355

356 from .orchestrator import MalaOrchestrator

357

358 # Load MalaConfig if not provided

359 if mala_config is None:

360 mala_config = MalaConfig.from_env(validate=False)

361

362 # Derive computed configuration

363 derived = _derive_config(config, mala_config)

364

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

372

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)

383

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 )