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

1"""Factory function for MalaOrchestrator initialization. 

2 

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

4pattern with explicit configuration and dependency dataclasses. 

5 

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 

10 

11Usage: 

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 

28from __future__ import annotations 

29 

30import os 

31import shutil 

32from typing import TYPE_CHECKING, cast 

33 

34from src.infra.tools.env import USER_CONFIG_DIR 

35 

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) 

43 

44# Re-export for backwards compatibility 

45__all__ = [ 

46 "DEFAULT_AGENT_TIMEOUT_MINUTES", 

47 "OrchestratorConfig", 

48 "OrchestratorDependencies", 

49 "create_orchestrator", 

50] 

51 

52import logging 

53 

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 

66 

67 from .orchestrator import MalaOrchestrator 

68 

69logger = logging.getLogger(__name__) 

70 

71 

72def _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 

132def _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 

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. 

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 

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. 

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 )