Source code for exlab_wizard.api.routers.operations
"""``/operations`` router. Backend Spec §4.6.1, Frontend §9.5.
Lists all in-flight controller operations. The Frontend's Operations
panel (Frontend §9.5) renders one entry per session: ``id``, ``state``,
``started_at``, ``equipment_id``, ``project_short_id``, ``run_label``,
optional ``plugin_name`` (when in ``INPUT_REQUIRED``), and optional
``suspended_reason`` (the reason string from the
``PluginInputRequired`` payload).
The endpoint reads the in-memory :class:`SessionStore` directly via
the controller; non-terminal sessions are returned in chronological
order so the panel is stable across refreshes.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, ConfigDict
from exlab_wizard.api._dependencies import require_controller
from exlab_wizard.api.setup import setup_state_gate
# Import from the submodules (not the ``exlab_wizard.controller`` package)
# to avoid a circular import: ``api.app`` pulls in this router while the
# controller package's ``__init__`` is still initializing, so reading
# attributes off the partially-built package would fail.
from exlab_wizard.controller.session_store import on_operations_panel, project_identifier
from exlab_wizard.controller.state_machine import SessionState
from exlab_wizard.utils.time import dt_to_iso
__all__ = ["OperationEntry", "OperationsResponse", "build_operations_router"]
[docs]
class OperationEntry(BaseModel):
"""One row in the Operations panel. Backend Spec §4.6.1, Frontend §9.5."""
model_config = ConfigDict(extra="forbid")
id: str
state: str
started_at: str
equipment_id: str | None = None
project_short_id: str | None = None
run_label: str | None = None
plugin_name: str | None = None
suspended_reason: str | None = None
[docs]
class OperationsResponse(BaseModel):
"""``GET /operations`` response."""
model_config = ConfigDict(extra="forbid")
operations: list[OperationEntry]
[docs]
def build_operations_router() -> APIRouter:
"""Construct the ``/operations`` router."""
router = APIRouter(tags=["operations"])
@router.get(
"/operations",
response_model=OperationsResponse,
dependencies=[Depends(setup_state_gate)],
)
async def list_operations(request: Request) -> OperationsResponse:
controller = require_controller(request)
sessions = controller.session_store
operations: list[OperationEntry] = []
for sid, session in sessions.iter_sorted():
# Terminal-success and explicit-cancel rows fall off the panel;
# FAILED rows stay so the operator can see the recent failure
# (Frontend §9.5 -- the shared membership rule).
if not on_operations_panel(session):
continue
operations.append(_session_to_entry(sid, session))
return OperationsResponse(operations=operations)
return router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _session_to_entry(session_id: str, session: Any) -> OperationEntry:
request = session.request
plugin_name: str | None = None
suspended_reason: str | None = None
if session.pending_input is not None:
plugin_name = session.pending_input.get("plugin")
suspended_reason = session.pending_input.get("reason")
return OperationEntry(
id=session_id,
state=session.state.value
if isinstance(session.state, SessionState)
else str(session.state),
started_at=dt_to_iso(session.created_at) if session.created_at is not None else "",
equipment_id=getattr(request, "equipment_id", None),
project_short_id=project_identifier(request),
run_label=getattr(request, "label", None),
plugin_name=plugin_name,
suspended_reason=suspended_reason,
)