Source code for exlab_wizard.api.routers.staging

"""``/staging`` router. Backend Spec §13.7, §13.8.

Four endpoints back the orchestrator's staging panel:

* ``GET /staging`` -- enumerate every staged run with its lifecycle
  state, file count, byte total, and elapsed time since last activity.
* ``POST /staging/{run_path}/force-sync`` -- enqueue an immediate
  NAS sync for a specific run (used when the operator wants to skip
  the watcher's polling latency).
* ``POST /staging/{run_path}/clear`` -- delete the local staging copy
  of a sync-verified run (the manual-mode action from §13.7).
* ``POST /staging/clear-verified`` -- bulk-clear every sync-verified
  staged run (Redesign §4.6 footer action).

All four return ``503`` with ``{"code": "internal_error"}`` when no
``Config`` is wired on the app (Redesign §3.1 made the orchestrator
pipeline unconditional, so the legacy ``orchestrator.enabled`` toggle
is gone).
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict

from exlab_wizard.api._dependencies import require_deps
from exlab_wizard.config.models import Config
from exlab_wizard.constants import RunSyncState, SyncHandleState
from exlab_wizard.logging import get_logger
from exlab_wizard.orchestrator.staging_clear import clear_run_dir
from exlab_wizard.orchestrator.staging_query import (
    StagedRunSummary,
    list_staged_runs,
)
from exlab_wizard.utils.time import utc_now

__all__ = [
    "ClearResponse",
    "ClearVerifiedResponse",
    "ForceSyncResponse",
    "KeepLocalRequest",
    "KeepLocalResponse",
    "StagedRunRow",
    "StagingListResponse",
    "build_staging_router",
]

_log = get_logger(__name__)


# ---------------------------------------------------------------------------
# Pydantic response models
# ---------------------------------------------------------------------------


[docs] class StagedRunRow(BaseModel): """One staging-panel row. Backend Spec §13.8.""" model_config = ConfigDict(extra="forbid") path: str current_state: str equipment_id: str project_name: str run_kind: str file_count: int byte_total: int elapsed_seconds_since_last_activity: int last_activity_at: str
[docs] class StagingListResponse(BaseModel): """``GET /staging`` response.""" model_config = ConfigDict(extra="forbid") runs: list[StagedRunRow]
[docs] class ForceSyncResponse(BaseModel): """``POST /staging/{run_path}/force-sync`` response.""" model_config = ConfigDict(extra="forbid") run_path: str state: str # "queued" or "blocked" job_id: str | None = None
[docs] class ClearResponse(BaseModel): """``POST /staging/{run_path}/clear`` response.""" model_config = ConfigDict(extra="forbid") run_path: str files_freed: int bytes_freed: int
[docs] class ClearVerifiedResponse(BaseModel): """``POST /staging/clear-verified`` response (Redesign §4.6).""" model_config = ConfigDict(extra="forbid") cleared_paths: list[str]
[docs] class KeepLocalRequest(BaseModel): """``POST /staging/{run_path}/keep-local`` request body. Operator-free per-file NAS sync design (2026-05-21): the operator toggles a file's ``keep_local`` flag from the file-list context menu. ``relative_path`` is the run-relative POSIX path of the file within the run directory; ``keep_local`` is the desired flag value. """ model_config = ConfigDict(extra="forbid") relative_path: str keep_local: bool
[docs] class KeepLocalResponse(BaseModel): """``POST /staging/{run_path}/keep-local`` response.""" model_config = ConfigDict(extra="forbid") run_path: str relative_path: str keep_local: bool
# --------------------------------------------------------------------------- # Router builder # ---------------------------------------------------------------------------
[docs] def build_staging_router() -> APIRouter: """Construct the ``/staging`` router. Backend Spec §13.7, §13.8.""" router = APIRouter(tags=["staging"]) @router.get("/staging", response_model=StagingListResponse) async def get_staging(request: Request) -> StagingListResponse: deps = require_deps(request) config = _require_config(deps) rows = list_staged_runs( config=config, now_utc=utc_now(), sync_state_writer=getattr(deps, "sync_state_writer", None), ) return StagingListResponse(runs=[_row_from_summary(s) for s in rows]) @router.post( "/staging/clear-verified", response_model=ClearVerifiedResponse, ) async def post_clear_verified(request: Request) -> ClearVerifiedResponse: """Bulk-clear every staged run whose NAS sync is verified. Redesign §4.6: the file-explorer footer's "Clear verified runs" action. Phase 5 keys "clearable" off the ``sync_state.json`` ``SYNCED`` rollup -- a run is clearable when every tracked file is verified on the NAS and the run has not already been cleared. """ deps = require_deps(request) config = _require_config(deps) cleared: list[str] = [] for summary in list_staged_runs( config=config, sync_state_writer=getattr(deps, "sync_state_writer", None), ): # Only a fully-SYNCED run is clearable; ``cleared`` runs have # no staging copy left and ``syncing`` runs are unproven. if summary.current_state != RunSyncState.SYNCED.value: continue run_path = Path(summary.path) try: files, _bytes = clear_run_dir(run_path) except Exception as exc: _log.warning("clear-verified: clear failed for %s: %s", run_path, exc) continue if files > 0: cleared.append(str(run_path)) _log.info("clear-verified bulk action: cleared=%d", len(cleared)) return ClearVerifiedResponse(cleared_paths=cleared) @router.post( "/staging/{run_path:path}/keep-local", response_model=KeepLocalResponse, ) async def post_keep_local( request: Request, run_path: str, body: KeepLocalRequest, ) -> KeepLocalResponse: """Toggle a file's ``keep_local`` flag in the run's ``sync_state.json``. Operator-free per-file NAS sync design (2026-05-21): a ``keep_local`` file still syncs to the NAS but is excluded from cleanup deletion. ``sync_state.json`` has a single writer -- the orchestrator's :class:`SyncStateWriter` -- so the GUI never writes the file directly; it calls this endpoint instead. Returns 503 when no ``SyncStateWriter`` is wired on the app instance, and 404 when ``run_path`` is not a real run inside an allowed root. """ deps = require_deps(request) config = _require_config(deps) writer = getattr(deps, "sync_state_writer", None) if writer is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail={ "code": "sync_state_writer_unavailable", "message": "sync-state writer is not wired on this app instance", }, ) # Path-containment guard: ``run_path`` comes straight from the URL # and ``SyncStateWriter`` *creates* ``<run_path>/.exlab-wizard/ # sync_state.json``. Reject any path that is not a real run -- it # must sit under an allowed root and carry a ``creation.json`` # cache -- so a hostile path (``%2Fetc``) cannot provoke a write. path = Path(run_path) if not _is_real_run(path, config): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={ "code": "session_not_found", "message": f"no run found at {run_path}", }, ) await writer.set_keep_local(path, body.relative_path, body.keep_local) _log.info( "keep-local toggled via API: run=%s file=%s value=%s", run_path, body.relative_path, body.keep_local, ) return KeepLocalResponse( run_path=run_path, relative_path=body.relative_path, keep_local=body.keep_local, ) @router.post( "/staging/{run_path:path}/force-sync", response_model=ForceSyncResponse, ) async def post_force_sync(request: Request, run_path: str) -> ForceSyncResponse: deps = require_deps(request) _require_config(deps) nas_sync = _require_nas_sync(deps) path = Path(run_path) handle = await nas_sync.enqueue(path) # ``handle`` is a SyncJobHandle-like object exposing .state / .job_id. state_value = getattr(handle, "state", SyncHandleState.QUEUED) job_id_value = getattr(handle, "job_id", None) or None _log.info( "force-sync requested via API: path=%s state=%s job_id=%s", run_path, state_value, job_id_value, ) return ForceSyncResponse( run_path=run_path, state=str(state_value), job_id=job_id_value, ) @router.post( "/staging/{run_path:path}/clear", response_model=ClearResponse, ) async def post_clear(request: Request, run_path: str) -> ClearResponse: deps = require_deps(request) config = _require_config(deps) path = Path(run_path) # Defensive check: only a fully-SYNCED run may be cleared. Phase 5 # derives the run rollup from ``sync_state.json``; a ``syncing`` run # is unproven and a ``cleared`` run has no staging copy left. from exlab_wizard.cache.sync_state_writer import SyncStateWriter writer = getattr(deps, "sync_state_writer", None) if writer is not None: state = SyncStateWriter.rollup_state(writer.read_sync(path)).value if state != RunSyncState.SYNCED.value: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ "code": "staging_not_sync_verified", "message": ( f"Cannot clear run in sync state {state!r}; " "only synced runs may be cleared." ), }, ) _ = config # kept for parity / future hooks files_freed, bytes_freed = clear_run_dir(path) return ClearResponse( run_path=run_path, files_freed=files_freed, bytes_freed=bytes_freed, ) return router
# --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _row_from_summary(summary: StagedRunSummary) -> StagedRunRow: return StagedRunRow( path=summary.path, current_state=summary.current_state, equipment_id=summary.equipment_id, project_name=summary.project_name, run_kind=summary.run_kind, file_count=summary.file_count, byte_total=summary.byte_total, elapsed_seconds_since_last_activity=summary.elapsed_seconds_since_last_activity, last_activity_at=summary.last_activity_at, ) def _is_real_run(run_path: Path, config: Config) -> bool: """Return True when ``run_path`` is a real run inside an allowed root. A "real run" is a directory that (a) sits under one of the configured roots (local_root / staging_root / templates / plugins) -- reusing the same containment helper the ``GET /folder`` endpoint uses -- and (b) carries a ``.exlab-wizard/creation.json`` cache. The containment check guards against a hostile URL path; the ``creation.json`` check ensures ``SyncStateWriter`` only ever creates ``sync_state.json`` under a genuine run directory. """ from exlab_wizard.api.routers.browse import _path_is_under_allowed_root from exlab_wizard.paths import creation_json_path try: resolved = run_path.resolve() except OSError: return False if not _path_is_under_allowed_root(resolved, config): return False return creation_json_path(run_path).exists() def _require_config(deps: Any) -> Config: """Return the live :class:`Config` or raise 503 when no config is wired. Redesign §3.1: the orchestrator pipeline is always active, so the staging endpoints only need to check that a config is present. """ config = getattr(deps, "config", None) if config is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail={ "code": "internal_error", "message": "config is not wired on this app instance", }, ) return config def _require_nas_sync(deps: Any) -> Any: nas_sync = getattr(deps, "nas_sync", None) if nas_sync is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail={ "code": "internal_error", "message": "NAS sync client is not wired on this app instance", }, ) return nas_sync