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
from exlab_wizard.controller 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] = [] # SessionStore exposes a private ``_sessions`` dict; iterate # explicitly rather than reaching into the dict so the public # surface stays narrow. for sid, session in _iter_sessions(sessions): if session.state in (SessionState.DONE, SessionState.ABORTED): # Terminal-success and explicit-cancel rows fall off # the panel; FAILED rows stay so the operator can see # the recent failure. continue operations.append(_session_to_entry(sid, session)) return OperationsResponse(operations=operations) return router
# --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _iter_sessions(store: Any) -> list[tuple[str, Any]]: """Return ``(session_id, session)`` pairs from the store. The :class:`SessionStore` keeps its dict private; we use the documented contract that ``store._sessions`` is a ``dict``. A public accessor would be cleaner; until that lands the shim here is the single touchpoint. """ sessions = getattr(store, "_sessions", {}) if not isinstance(sessions, dict): return [] return sorted( sessions.items(), key=lambda pair: getattr(pair[1], "created_at", None) or 0, ) 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_short_id(request), run_label=getattr(request, "label", None), plugin_name=plugin_name, suspended_reason=suspended_reason, ) def _project_short_id(request: Any) -> str | None: """Pluck the project short id off a project / run request.""" short = getattr(request, "project_short_id", None) if short: return short lims_project = getattr(request, "lims_project", None) if isinstance(lims_project, dict): value = lims_project.get("short_id") return value if isinstance(value, str) and value else None return None