""" Pipeline control — retry / force-node / pause / resume / state. Wraps an in-memory engine (set by main.py) and emits events on the bus so the UI live stream picks them up. """ from fastapi import APIRouter from pydantic import BaseModel from typing import Any, Optional import threading from backend.api.api import bus from backend.events.schema import EventType router = APIRouter(prefix="/api/pipeline", tags=["pipeline"]) # F-15: guards for module-level mutable state used from async handlers. _engine_lock = threading.Lock() _state_lock = threading.Lock() # In-memory engine reference (set by main.py) _ENGINE: Optional[Any] = None _PIPELINE_STATE: dict = { "state": "idle", # idle | running | paused | complete | failed "active_actions": [], "completed": 0, "failed": 0, "queued": 0, "build_id": None, } class ActionBody(BaseModel): action: str node: Optional[str] = None class ForceBody(BaseModel): action: str node: str def set_engine(engine: Any) -> None: global _ENGINE with _engine_lock: _ENGINE = engine def get_state_snapshot() -> dict: with _state_lock: return dict(_PIPELINE_STATE) # ----------------------------- # GET STATE # ----------------------------- @router.get("/state") def get_state(): return get_state_snapshot() # ----------------------------- # RETRY ACTION # ----------------------------- @router.post("/retry") def retry_action(body: ActionBody): bus.emit( EventType.PIPELINE_UPDATE, action=body.action, node=body.node, state="retrying", reason="manual_retry", ) # In a real engine this would re-queue the action. # For now we just record the intent. return {"status": "retry_queued", "action": body.action, "node": body.node} # ----------------------------- # FORCE NODE # ----------------------------- @router.post("/force-node") def force_node(body: ForceBody): bus.emit( EventType.PIPELINE_UPDATE, action=body.action, node=body.node, state="forced", reason="manual_force", ) return {"status": "forced", "action": body.action, "node": body.node} # ----------------------------- # PAUSE # ----------------------------- @router.post("/pause") def pause() -> dict: with _state_lock: _PIPELINE_STATE["state"] = "paused" eng = _ENGINE # brief read; lock not strictly needed for read of immutable ref if eng and hasattr(eng, "pause"): eng.pause() bus.emit(EventType.PIPELINE_UPDATE, state="paused", reason="manual_pause") return {"status": "paused"} # ----------------------------- # RESUME # ----------------------------- @router.post("/resume") def resume() -> dict: with _state_lock: _PIPELINE_STATE["state"] = "running" eng = _ENGINE if eng and hasattr(eng, "resume"): eng.resume() bus.emit(EventType.PIPELINE_UPDATE, state="running", reason="manual_resume") return {"status": "running"} # ----------------------------- # Legacy wrapper for api.py facade # ----------------------------- def pipeline_control_endpoint(action: str, payload: Optional[dict] = None, bus: Optional[Any] = None) -> dict: """Legacy dispatcher. ``action`` is one of: ``retry``, ``force``, ``pause``, ``resume``, ``state``. F-11 remediation: the if/elif chain is replaced with a dispatch table so adding a new action is a one-line entry. """ payload = payload or {} def _retry(): return retry_action(ActionBody(**payload)) def _force(): return force_node(ForceBody(**payload)) handlers = { "retry": _retry, "force": _force, "pause": pause, "resume": resume, "state": get_state_snapshot, } fn = handlers.get(action) if fn is None: return {"error": f"unknown action: {action}"} return fn()