""" BuildRunner — manages build lifecycle. Wraps PipelineEngine with: - Build ID generation - State tracking (queued, running, complete, failed) - Async task management (so /api/build returns immediately) - Optional persistence via a storage backend Usage: runner = BuildRunner(bus, registry, nodes) build_id = await runner.start(BuildBody(cmd="make -j4", dir="/tmp/foo")) state = runner.state(build_id) all_builds = runner.list_builds() """ import asyncio import time import uuid from dataclasses import dataclass, field, asdict from typing import Any, Dict, List, Optional from backend.events.bus import EventBus from backend.events.schema import EventType from backend.pipeline.engine import PipelineEngine from backend.cache.minio_cache import MinioCache # Optional storage backend try: from backend.storage.sqlite_db import STORAGE except Exception: STORAGE = None @dataclass class BuildRecord: build_id: str cmd: str dir: str project: Optional[str] = None arch: str = "x86_64" target: str = "linux-gnu" toolchain: str = "gcc" started_at: float = field(default_factory=time.time) ended_at: Optional[float] = None status: str = "queued" # queued | running | complete | failed actions: List[Dict[str, str]] = field(default_factory=list) error: Optional[str] = None def to_dict(self) -> Dict[str, Any]: return asdict(self) class BuildRunner: """Manages build lifecycle and tracks all builds in memory (or via storage).""" def __init__(self, bus: EventBus, registry, nodes: List[Dict[str, Any]], storage=None): self.bus = bus self.registry = registry self.nodes = nodes self.storage = storage if storage is not None else STORAGE # may still be None self._builds: Dict[str, BuildRecord] = {} self._tasks: Dict[str, asyncio.Task] = {} self._engines: Dict[str, PipelineEngine] = {} # build_id -> engine self._cache: Optional[MinioCache] = None # lazy init # On startup, load any persisted builds into the in-memory cache if self.storage: self.load_from_storage() # Wire engines into debugger + pipeline_control so they can # pause/resume/step the most-recently-started build. self._wire_debugger() def _wire_debugger(self): """Register our engine getter with the debugger + pipeline_control modules.""" try: from backend.api import debugger as dbg from backend.api import pipeline_control as pc # Replace their _ENGINE singletons with a proxy that always # points at the most recent running build's engine. proxy = _EngineProxy(self) dbg.set_engine(proxy) pc.set_engine(proxy) except Exception as e: # Don't crash if debugger/pipeline_control aren't importable pass # ------------------------------------------------- # CACHE # ------------------------------------------------- def _get_cache(self) -> Optional[MinioCache]: """Lazily construct a MinioCache. Returns None if MinIO is unreachable or not configured.""" if self._cache is not None: return self._cache try: self._cache = MinioCache("localhost:9000", "minioadmin", "minioadmin") # Force connection test self._cache._ensure_client() return self._cache except Exception: self._cache = None return None # ------------------------------------------------- # START A BUILD # ------------------------------------------------- async def start(self, body) -> str: """Queue a build and return its ID. The actual run happens in a background task.""" build_id = str(uuid.uuid4())[:8] record = BuildRecord( build_id=build_id, cmd=body.cmd, dir=body.dir, project=getattr(body, "project", None), arch=getattr(body, "arch", "x86_64"), target=getattr(body, "target", "linux-gnu"), toolchain=getattr(body, "toolchain", "gcc"), ) record.status = "running" self._builds[build_id] = record self._persist(record) # Launch in background task = asyncio.create_task(self._run_build(build_id)) self._tasks[build_id] = task return build_id # ------------------------------------------------- # BUILD EXECUTION # ------------------------------------------------- async def _run_build(self, build_id: str): record = self._builds[build_id] try: # Construct a project spec the engine can consume project = self._build_project_spec(record) # Choose execution mode if record.cmd and record.cmd.startswith("make"): # Real build — use the actual engine engine = PipelineEngine( nodes=self.nodes, node_registry=self.registry, event_bus=self.bus, cache=self._get_cache(), build_id=build_id, ) self._engines[build_id] = engine try: results = await engine.run(project) finally: # Engine done — drop from active set self._engines.pop(build_id, None) # Record results record.actions = [{"name": n, "state": s} for n, s in results] record.status = "failed" if any(s == "failed" for _, s in results) else "complete" else: # Synthetic pipeline for non-shell build commands (demo mode) await self._run_synthetic(build_id, record) record.status = "complete" except Exception as e: record.status = "failed" record.error = f"{type(e).__name__}: {e}" self.bus.emit( EventType.FAILURE, action=None, node=None, state="failed", reason=f"build_crashed:{record.error}", meta={"build_id": build_id}, ) finally: record.ended_at = time.time() self._persist(record) def _build_project_spec(self, record: BuildRecord) -> Dict[str, Any]: """Convert a BuildRecord into a project spec the engine understands.""" return { "source": record.project or "local", "default_target": record.target, "build_dir": record.dir, # passed through to actions as `dir` "build_env": { "ARCH": record.arch, "TARGET": record.target, "CC": record.toolchain, "DISTCC_HOSTS": " ".join( n.get("host", "") for n in self.nodes if n.get("state") == "online" ), }, "targets": { record.target: record.cmd, }, } async def _run_synthetic(self, build_id: str, record: BuildRecord): """Fallback synthetic pipeline — emits a realistic-looking stream of events without actually running any commands. Used when the build command isn't a real shell command (e.g. demo / smoke test).""" import random actions = [ ("fetch_source", []), ("configure", ["fetch_source"]), ("build_kernel", ["configure"]), ("build_modules", ["configure"]), ("build_initramfs", ["configure"]), ("build_debian", ["build_kernel", "build_modules"]), ("package", ["build_debian"]), ("release", ["package"]), ] critical = {"fetch_source", "configure", "build_kernel", "build_debian", "package", "release"} for action, deps in actions: node_name = random.choice([n["name"] for n in self.nodes if n.get("state") != "offline"]) if self.nodes else "localhost" self.bus.emit(EventType.TASK_UPDATE, action=action, node=node_name, state="scheduled", meta={"deps": deps, "critical": action in critical, "target": record.target, "build_id": build_id}) await asyncio.sleep(0.2) self.bus.emit(EventType.TASK_UPDATE, action=action, node=node_name, state="running", meta={"deps": deps, "critical": action in critical, "target": record.target, "build_id": build_id}) await asyncio.sleep(random.uniform(0.4, 1.0)) cache_hit = random.random() < 0.25 self.bus.emit(EventType.TASK_UPDATE, action=action, node=node_name, state="done", meta={"deps": deps, "cache": "hit" if cache_hit else None, "critical": action in critical, "target": record.target, "build_id": build_id}) if cache_hit: self.bus.emit(EventType.CACHE_UPDATE, action=action, node=node_name, state="hit", meta={"build_id": build_id}) record.actions.append({"name": action, "state": "done"}) await asyncio.sleep(0.1) # ------------------------------------------------- # QUERY # ------------------------------------------------- def state(self, build_id: str) -> Optional[Dict[str, Any]]: rec = self._builds.get(build_id) if rec: return rec.to_dict() if self.storage: try: return self.storage.get_build(build_id) except Exception: return None return None def list_builds(self) -> List[Dict[str, Any]]: # Merge in-memory + storage (in-memory takes precedence) in_mem = {b.build_id: b.to_dict() for b in self._builds.values()} if not self.storage: return list(in_mem.values()) try: stored = {b["build_id"]: b for b in self.storage.list_builds()} except Exception: stored = {} # Merge: in-memory wins (it has fresher state for running builds) merged = {**stored, **in_mem} return sorted(merged.values(), key=lambda b: b.get("started_at", 0), reverse=True) def cancel(self, build_id: str) -> bool: task = self._tasks.get(build_id) if task and not task.done(): task.cancel() rec = self._builds.get(build_id) if rec: rec.status = "cancelled" rec.ended_at = time.time() self._persist(rec) return True return False # ------------------------------------------------- # DEBUGGER CONTROL # ------------------------------------------------- def get_engine(self, build_id: Optional[str] = None) -> Optional[PipelineEngine]: """Look up the running engine for a build. If build_id is None, returns the most recently started engine.""" if build_id: return self._engines.get(build_id) # Return the most recent (last-inserted) engine if not self._engines: return None return list(self._engines.values())[-1] def active_build_ids(self) -> List[str]: """Return IDs of builds with engines currently running.""" return list(self._engines.keys()) # ------------------------------------------------- # PERSISTENCE # ------------------------------------------------- def _persist(self, record: BuildRecord): if self.storage: try: self.storage.save_build(record.to_dict()) except Exception: pass # storage errors are non-fatal def load_from_storage(self): """On startup, load any persisted builds into the in-memory cache.""" if not self.storage: return try: builds = self.storage.list_builds() for b in builds: # Don't overwrite running builds (they're stale from a crashed process) if b.get("status") == "running": b["status"] = "crashed" b["error"] = "process restarted while build was running" rec = BuildRecord(**{k: v for k, v in b.items() if k in BuildRecord.__dataclass_fields__}) self._builds[rec.build_id] = rec except Exception: pass class _EngineProxy: """Stand-in object that the debugger + pipeline_control modules can call pause/resume/step on. Forwards calls to the BuildRunner's most-recent active engine, so debugger buttons affect the currently-running build.""" def __init__(self, runner: BuildRunner): self._runner = runner def _engine(self) -> Optional[PipelineEngine]: return self._runner.get_engine() def pause(self): eng = self._engine() if eng: eng.pause() return True return False def resume(self): eng = self._engine() if eng: eng.resume() return True return False def step(self): eng = self._engine() if eng: eng.step() return True return False def step_back(self): # Step-back is replay-only; live engine can't undo return False def is_active(self) -> bool: return self._engine() is not None