fester/backend/api/pipeline_control.py

153 lines
3.9 KiB
Python
Executable File

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