Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 137 additions & 40 deletions docs/REMAINING_WORK_TASKS.md

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion src/exlab_wizard/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from exlab_wizard.api.routers.staging import build_staging_router
from exlab_wizard.api.setup import build_setup_router
from exlab_wizard.config.models import Config
from exlab_wizard.constants import AUDIT_REFRESH_SECONDS, AuditScopeKind
from exlab_wizard.constants import AUDIT_REFRESH_SECONDS, AuditScopeKind, Tier
from exlab_wizard.logging import get_logger
from exlab_wizard.utils.time import utc_now_iso

Expand Down Expand Up @@ -218,6 +218,10 @@ class AppDependencies:
# Audit / pub-sub ---------------------------------------------------
audit_channel: AuditChannel | None = None
last_audit_at: str | None = None
# Tier counts from the latest background audit pass, read by the GUI
# Problems tab badge + right-pane summary (T6 / §B5).
last_audit_hard: int = 0
last_audit_soft: int = 0

# Health snapshot probes -------------------------------------------
nas_sync_snapshot: Callable[[], dict[str, Any]] | None = None
Expand All @@ -229,6 +233,14 @@ class AppDependencies:
lims_probe: Callable[..., Any] | None = None
equipment_probe: Callable[..., Any] | None = None
autostart_toggle: Callable[[bool], Any] | None = None
# Real platform autostart-registration state, seeded at tray build so
# Settings -> Application can reflect it (T8).
autostart_is_registered: bool = False
# Graceful-shutdown hook + tray-availability flag, attached by the tray
# builder so the in-window Settings -> Application section can quit (T9)
# and show real tray status (T11). Absent in headless / server-only runs.
request_quit: Callable[[], None] | None = None
tray_available: bool = False

# Background tasks --------------------------------------------------
audit_task: asyncio.Task[None] | None = field(default=None, repr=False)
Expand Down Expand Up @@ -343,6 +355,16 @@ async def _audit_loop(deps: AppDependencies, interval_seconds: float) -> None:
continue
audit_at = utc_now_iso()
deps.last_audit_at = audit_at
# Cache tier counts so the in-process GUI (Problems tab badge +
# right-pane summary) reads them straight off deps -- a single
# source, refreshed on the 30 s cadence -- without re-running a
# full O(tree) audit on every page render (T6 / §B5).
deps.last_audit_hard = sum(
1 for f in findings if getattr(f, "tier", "") == Tier.HARD.value
)
deps.last_audit_soft = sum(
1 for f in findings if getattr(f, "tier", "") == Tier.SOFT.value
)
added, removed, changed = _diff_findings(last, findings)
if deps.audit_channel is not None:
if not last:
Expand Down
60 changes: 13 additions & 47 deletions src/exlab_wizard/api/routers/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@

from exlab_wizard.api._dependencies import require_controller
from exlab_wizard.api.setup import setup_state_gate
from exlab_wizard.controller import SessionState

# 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"]
Expand Down Expand Up @@ -63,14 +69,11 @@ 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.
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)
Expand All @@ -83,23 +86,6 @@ async def list_operations(request: Request) -> OperationsResponse:
# ---------------------------------------------------------------------------


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
Expand All @@ -114,28 +100,8 @@ def _session_to_entry(session_id: str, session: Any) -> OperationEntry:
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),
project_short_id=project_identifier(request),
run_label=getattr(request, "label", None),
plugin_name=plugin_name,
suspended_reason=suspended_reason,
)


def _project_short_id(request: Any) -> str | None:
"""Pluck a project identifier off a project / run request.

A project request carries the LIMS ``short_id`` in its
``lims_project`` block; a run request carries only the parent
project's folder name (the human-readable LIMS name, Backend Spec
§3.2), so that name is used as the operations-panel identifier.
"""
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")
if isinstance(value, str) and value:
return value
project_name = getattr(request, "project_name", None)
return project_name if isinstance(project_name, str) and project_name else None
9 changes: 8 additions & 1 deletion src/exlab_wizard/controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
RunCreateRequest,
SessionHandle,
)
from exlab_wizard.controller.session_store import Session, SessionStore
from exlab_wizard.controller.session_store import (
Session,
SessionStore,
on_operations_panel,
project_identifier,
)
from exlab_wizard.controller.state_machine import (
VALID_TRANSITIONS,
Phase,
Expand All @@ -43,5 +48,7 @@
"SessionState",
"SessionStore",
"assert_transition",
"on_operations_panel",
"project_identifier",
"state_to_phase",
]
Loading
Loading