Source code for exlab_wizard.ui.mount

"""NiceGUI mount helper. Backend Spec §4.3, §15.3.2.

``mount_ui`` is the single entry point the tray calls after
``create_app`` returns: it registers ``@ui.page(...)`` handlers for every
wizard route and binds the NiceGUI ASGI sub-app onto the FastAPI app at
``/`` via ``ui.run_with``. Page handlers pull live components from
``app.state.dependencies`` -- the API surface and the GUI share the same
dependency bundle.

The handlers are deliberately defensive: every dependency access is
wrapped in try/except so a half-wired backend (LIMS not reachable, sync
queue absent, validator not yet vetted) degrades to a structured
"unavailable" banner instead of leaking a stack trace into pywebview.
The factory in :mod:`exlab_wizard.tray.dependencies` follows the same
pattern at construction time; the two layers together let the operator
see a usable GUI even when individual collaborators are unavailable.
"""

from __future__ import annotations

import asyncio
import contextlib
import os
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any

from exlab_wizard.constants import (
    KEYRING_USERNAME_LIMS,
    AuditScopeKind,
    RunKind,
    SetupState,
)
from exlab_wizard.logging import get_logger

# clear_run_dir backs the per-run tree context-menu "clear" action (force-sync /
# clear / view-log), which applies to nas-mode runs too — kept after the staging
# dock was hidden. See
# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md.
from exlab_wizard.orchestrator.staging_clear import clear_run_dir

if TYPE_CHECKING:
    from fastapi import FastAPI

    from exlab_wizard.template.resolution import TemplateChoices


__all__ = ["MOUNT_PATH", "mount_ui"]

_log = get_logger(__name__)

MOUNT_PATH = "/"

# Strong references for the fire-and-forget asyncio tasks the mount
# spawns (force-sync, clear, bulk clear-verified). asyncio is documented
# to drop tasks whose only reference is the event loop, so we hold them
# in a module-level set until they finish to prevent unexpected
# cancellation under load.
_BACKGROUND_TASKS: set[asyncio.Task[Any]] = set()


def _spawn_background(coro: Any) -> asyncio.Task[Any]:
    """Schedule ``coro`` and keep a strong reference until it finishes."""
    task = asyncio.create_task(coro)
    _BACKGROUND_TASKS.add(task)
    task.add_done_callback(_BACKGROUND_TASKS.discard)
    return task


[docs] def mount_ui(app: FastAPI, *, storage_secret: str) -> None: """Register every wizard page on ``app`` and mount NiceGUI at ``/``. ``storage_secret`` is the per-installation token from :mod:`exlab_wizard.tray.storage_secret`; NiceGUI uses it to sign the Starlette ``SessionMiddleware`` cookie that backs ``app.storage.user``. The codebase doesn't read ``app.storage.*`` today but NiceGUI refuses to mount without a non-empty value. """ from nicegui import ui from exlab_wizard.ui.theme import register_static_assets, register_theme register_static_assets() # Inject the canonical :root design-token block app-wide (shared=True) so # every page's components resolve var(--color-*)/var(--sp-*)/... to real # values rather than falling back to scattered literals (the block was # previously never wired into the running app). register_theme() _register_pages(app, ui) ui.run_with( app, mount_path=MOUNT_PATH, show_welcome_message=False, storage_secret=storage_secret, )
def _register_pages(app: FastAPI, ui: Any) -> None: """Define every ``@ui.page(...)`` handler. Called from :func:`mount_ui`.""" from exlab_wizard.ui.pages import ( main as main_page, ) from exlab_wizard.ui.pages import ( problems as problems_page, ) from exlab_wizard.ui.pages import ( settings as settings_page, ) from exlab_wizard.ui.pages import ( template_editor as template_editor_page, ) from exlab_wizard.ui.pages import ( templates as templates_page, ) from exlab_wizard.ui.pages import ( welcome as welcome_page, ) from exlab_wizard.ui.pages import ( wizard_equipment as wizard_equipment_page, ) from exlab_wizard.ui.pages import ( wizard_project as wizard_project_page, ) def _deps() -> Any: return getattr(app.state, "dependencies", None) @ui.page("/") def _index() -> Any: deps = _deps() if _is_setup_ready(deps): ui.navigate.to("/main") else: ui.navigate.to("/welcome") @ui.page("/welcome") def _welcome() -> Any: def _on_started(autostart: bool) -> None: _apply_autostart(_deps(), autostart) ui.navigate.to("/settings") def _on_skip(autostart: bool) -> None: _apply_autostart(_deps(), autostart) ui.navigate.to("/main") return welcome_page.render_welcome_page( on_get_started=_on_started, on_skip=_on_skip, ) @ui.page("/main") def _main( selected: str = "", right_pane: str = "", file: str = "", q: str = "", density: str = "", ) -> Any: deps = _deps() from exlab_wizard.api.routers import browse as _browse config = getattr(deps, "config", None) hierarchy = _browse.build_hierarchy_dict(config) selected_path = selected or None node_kind, is_received = _classify_node(selected_path, hierarchy) right_pane_collapsed = right_pane == "collapsed" # Kick off / rebind the folder feed for the selected path and # gather the most recent payload (empty list on first render or if # the feed hasn't ticked yet). Resolved before the state build so a # ?file= selection can be matched against the in-memory feed. feed_entries = _drive_folder_feed(app, deps, selected_path) selected_file = _build_selected_file(file or None, feed_entries, deps) state = _build_main_state( deps, selected_node=selected_path, node_kind=node_kind, is_received=is_received, right_pane_collapsed=right_pane_collapsed, selected_file_path=file or None, selected_file=selected_file, search_query=q, density=density, ) metadata_payload = _build_metadata_payload(selected_path, node_kind, deps) def _refresh() -> None: ui.navigate.to( "/main" + _build_main_query(selected, right_pane, file=file, q=q, density=density) ) def _on_select_node(node_id: str) -> None: # Selecting a new tree node repaints the centre list for the new # folder, so any ?file= selection (a path under the *previous* # folder) is dropped; search query + density carry over. ui.navigate.to("/main" + _build_main_query(node_id, right_pane, q=q, density=density)) def _on_toggle_right_pane() -> None: new_pane = "" if right_pane == "collapsed" else "collapsed" ui.navigate.to( "/main" + _build_main_query(selected, new_pane, file=file, q=q, density=density) ) def _on_select_file(entry: Any) -> None: # Single-click on a file/folder row -> carry its path on ?file=. ui.navigate.to( "/main" + _build_main_query( selected, right_pane, file=getattr(entry, "path", ""), q=q, density=density ) ) def _on_refresh_folder() -> None: # Files-pane refresh: re-scan the open folder now, then re-render. _refresh_selected_folder(app, deps, selected_path) ui.navigate.to( "/main" + _build_main_query(selected, right_pane, file=file, q=q, density=density) ) def _on_search(query: str) -> None: # Typing in the search box re-filters the tree only; keep the # selected node, file selection, pane state and density and swap # just ?q= (debounced render-side so this fires once per pause). ui.navigate.to( "/main" + _build_main_query(selected, right_pane, file=file, q=query, density=density) ) def _on_toggle_density() -> None: # Files-pane row density (§4.8): flip compact <-> comfortable, # keep everything else, swap only ?density=. new_density = "" if density == "compact" else "compact" ui.navigate.to( "/main" + _build_main_query(selected, right_pane, file=file, q=q, density=new_density) ) def _on_run_staging_action(path: str, action: str) -> None: _run_staging_action(deps, path, action, ui) def _on_tree_context_action(node_id: str, action: str) -> None: # Either edit or remove deep-links into Settings with the # equipment pre-selected (Redesign §4.6 / decision 4A). del action # both actions route to the same destination today ui.navigate.to(f"/settings?active=equipment&equipment_id={node_id}") def _on_file_context_action(entry: Any, action: str) -> None: _file_context_action(deps, entry, action, ui, on_done=_refresh) return main_page.render_file_explorer_page( on_open_new_project=lambda: ui.navigate.to("/wizard/project"), on_open_new_run=lambda: ui.navigate.to("/wizard/run"), on_open_new_test_run=lambda: ui.navigate.to("/wizard/test-run"), on_open_add_equipment=lambda: ui.navigate.to("/wizard/equipment"), on_open_settings=lambda: ui.navigate.to("/settings"), on_refresh=_refresh, on_select_node=_on_select_node, on_open_operations=lambda: _open_operations_modal(deps, ui), on_navigate_breadcrumb=_on_select_node, on_toggle_right_pane=_on_toggle_right_pane, on_run_staging_action=_on_run_staging_action, on_tree_context_action=_on_tree_context_action, on_file_context_action=_on_file_context_action, on_select_file=_on_select_file, on_refresh_folder=_on_refresh_folder, on_search=_on_search, on_toggle_density=_on_toggle_density, state=state, hierarchy=hierarchy, file_list_entries=feed_entries, metadata_payload=metadata_payload, ) @ui.page("/wizard/project") async def _wizard_project() -> Any: deps = _deps() initial = _resolve_template_choices(deps, "project") def _resolve_project_templates(equipment_id: str | None) -> Any: # Per-equipment project templates layer over the global store # once the operator picks equipment (step 3, after the template # step -- so this reflects on a step-back). Backend Spec §5.0. return _resolve_template_choices(deps, "project", equipment_id=equipment_id) return wizard_project_page.render_project_wizard( templates=initial.names, equipment_ids=_equipment_ids(deps), template_questions=initial.questions, template_paths=initial.paths, on_resolve=_resolve_project_templates, lims_projects=await _lims_projects(deps), on_submit=lambda state: _submit_project(deps, state, ui), on_cancel=lambda: ui.navigate.to("/main"), ) @ui.page("/wizard/run") def _wizard_run() -> Any: deps = _deps() return _render_run_wizard(deps, RunKind.EXPERIMENTAL, ui) @ui.page("/wizard/test-run") def _wizard_test_run() -> Any: deps = _deps() return _render_run_wizard(deps, RunKind.TEST, ui) @ui.page("/wizard/equipment") def _wizard_equipment() -> Any: """Redesign §6 — Add-Equipment wizard route. ``state`` is created once for the wizard's whole lifetime: the render layer drives Next / Back internally (re-rendering in place), so nothing here navigates mid-wizard -- a navigation would rebuild the page and reset every field the operator typed. """ deps = _deps() state = wizard_equipment_page.EquipmentWizardState() def _on_confirm(eq: Any) -> None: # Persist straight into the live config (Redesign §6): merge # via the same shared helper the POST /config/equipment route # uses, save through ``deps.save_config``, then push the merged # config into the running NAS-sync client / poller via # ``apply_live_config`` so the new equipment is live without a # tray relaunch -- matching the route's no-restart contract. from exlab_wizard.config.models import config_with_equipment_appended from exlab_wizard.errors import ConfigError try: merged = config_with_equipment_appended(getattr(deps, "config", None), eq) except ConfigError as exc: _show_toast(ui, f"Could not add equipment: {exc}", positive=False) return saver = getattr(deps, "save_config", None) if deps is not None else None if saver is None: _show_toast( ui, "Cannot add equipment: no config writer is available", positive=False ) return try: result = saver(merged) if hasattr(result, "__await__"): # Production wires a synchronous saver; an awaitable # here would silently no-op, so surface it. _log.warning("save_config returned an awaitable; a sync saver is expected") except Exception as exc: _log.exception("append-equipment save_config failed") _show_toast(ui, f"Could not add equipment: {exc}", positive=False) return if deps is not None: _apply_live_config(deps, merged) ui.navigate.to("/main") return wizard_equipment_page.render_wizard_equipment( state=state, on_confirm=_on_confirm, on_cancel=lambda: ui.navigate.to("/main"), ) @ui.page("/templates") def _templates(loc: str = "global") -> Any: deps = _deps() # Global scaffolding always targets the flat templates_dir; the # location selector only changes which directories are *listed*. templates_dir = _templates_dir(deps) scan_dirs = _location_scan_dirs(deps, loc) def _on_create( name: str, template_type: str, description: str, run_scope: str | None ) -> None: if templates_dir is None: _show_toast(ui, "Set the templates directory in Settings first", positive=False) return try: templates_page.create_template( templates_dir, name=name, template_type=template_type, description=description, run_scope=run_scope, ) except Exception as exc: _show_toast(ui, f"Template not created: {exc}", positive=False) return _show_toast(ui, f"Template {name!r} created", positive=True) ui.navigate.to("/templates") # Merge the listings across the location's scan dirs (the global # location has one flat dir; an equipment location has its # type-segregated project/ + run/ stores). ``rel_by_name`` records # the on-disk path so the Edit link can deep-link straight to it. summaries: list[Any] = [] dir_by_name: dict[str, Path] = {} for directory in scan_dirs: for summary in templates_page.list_templates(directory): if summary.name in dir_by_name: continue dir_by_name[summary.name] = summary.path summaries.append(summary) def _on_edit(name: str) -> None: target = dir_by_name.get(name) if target is None: _show_toast(ui, f"Template {name!r} not found", positive=False) return ui.navigate.to(f"/templates/edit?dir={target}") return templates_page.render_template_manager( templates=summaries, on_create=_on_create, on_back=lambda: ui.navigate.to("/main"), on_edit=_on_edit, locations=_template_locations(deps), on_location_change=lambda value: ui.navigate.to(f"/templates?loc={value}"), ) @ui.page("/templates/edit") def _templates_edit(dir: str = "") -> Any: # query-param name (binds NiceGUI ?dir=) from exlab_wizard.template import authoring template_dir = Path(dir) if dir else None if template_dir is None or not template_dir.is_dir(): _show_toast(ui, "Template not found; returning to templates", positive=False) ui.navigate.to("/templates") return None def _reload() -> None: ui.navigate.to(f"/templates/edit?dir={template_dir}") def _on_save_manifest(manifest: Any) -> None: try: _, expected = authoring.read_manifest(template_dir) authoring.write_manifest(template_dir, manifest, expected_stat=expected) except ( authoring.StaleEditError, authoring.UnsafePathError, authoring.TemplateAuthoringError, ) as exc: _show_toast(ui, f"Questions not saved: {exc}", positive=False) return _show_toast(ui, "Questions saved", positive=True) _reload() def _on_save_content(rel: str, text: str) -> None: try: existing = template_dir / rel expected = None if existing.is_file(): _, expected = authoring.read_content(existing) authoring.write_content_file(template_dir, rel, text, expected_stat=expected) except ( authoring.StaleEditError, authoring.UnsafePathError, authoring.TemplateAuthoringError, ) as exc: _show_toast(ui, f"File not saved: {exc}", positive=False) return _show_toast(ui, f"Saved {rel}", positive=True) _reload() def _on_upload(filename: str, data: bytes, render_as_template: bool) -> None: try: authoring.upload_file( template_dir, filename, data, render_as_template=render_as_template ) except (authoring.UnsafePathError, authoring.TemplateAuthoringError) as exc: _show_toast(ui, f"Upload failed: {exc}", positive=False) return _show_toast(ui, f"Uploaded {filename}", positive=True) _reload() def _on_delete(rel: str) -> None: try: authoring.delete_path(template_dir, rel) except (authoring.UnsafePathError, authoring.TemplateAuthoringError) as exc: _show_toast(ui, f"Delete failed: {exc}", positive=False) return _show_toast(ui, f"Deleted {rel}", positive=True) _reload() return template_editor_page.render_template_editor( template_dir=template_dir, on_save_manifest=_on_save_manifest, on_save_content=_on_save_content, on_upload=_on_upload, on_delete=_on_delete, on_back=lambda: ui.navigate.to("/templates"), ) @ui.page("/settings") def _settings(active: str = "") -> Any: from exlab_wizard.api._dependencies import lims_password_present, nas_remote_available deps = _deps() incomplete = _missing_setup_sections(deps) # ``active`` is an optional deep-link query param; when absent the # page falls back to its own first-incomplete-section logic. state = ( settings_page.SettingsState( incomplete_sections=incomplete, active_section=active, ) if active else settings_page.SettingsState(incomplete_sections=incomplete) ) config = getattr(deps, "config", None) if deps is not None else None def _on_save(updated: Any) -> None: if not _persist_config(deps, updated, ui): return # Live-applied in-process (no relaunch): confirm and keep the # operator on the settings page so they can keep editing. _show_toast(ui, "Settings saved", positive=True) on_save_lims_password, on_clear_lims_password = _lims_credential_handlers(deps, ui) def _on_set_autostart(enabled: bool) -> bool | None: return _apply_autostart(deps, enabled) # Quit hook (T9): run the graceful-shutdown hook on a separate thread, # NOT via ui.timer. The timer callback runs on the server's *running* # event loop, where ``request_quit``'s ``asyncio.run(...)`` raises # "loop already running" (and the fallback re-raises) -- the app would # never shut down. A fresh thread has no running loop so ``asyncio.run`` # works; the click handler returns immediately so the HTTP response # still flushes. Absent in headless/test fixtures. _quit_hook = getattr(deps, "request_quit", None) if deps is not None else None on_quit: Callable[[], None] | None = None if _quit_hook is not None: quit_hook = _quit_hook def on_quit() -> None: import threading def _do() -> None: try: quit_hook() except Exception as exc: _log.warning("quit hook raised: %s", exc) threading.Thread(target=_do, name="exlab-quit", daemon=True).start() async def _on_test_connection(remote: str, config_path: str) -> Any: return await _nas_test_connection(remote, config_path) # ``on_select_section`` is left unset: the settings dialog swaps # sections client-side, so a navigation hook would only reload # the page and discard the operator's in-progress edits. return settings_page.render_settings_page( config=config, state=state, on_save=_on_save, on_discard=None, on_save_lims_password=on_save_lims_password, on_clear_lims_password=on_clear_lims_password, lims_password_present=lims_password_present(deps), nas_remote_available=lambda remote: nas_remote_available(deps, remote), on_test_connection=_on_test_connection, nas_remotes=getattr(deps, "nas_remotes", ()) if deps is not None else (), list_remotes=_list_nas_remotes, autostart_registered=bool(getattr(deps, "autostart_is_registered", False)), on_set_autostart=_on_set_autostart, on_quit=on_quit, tray_available=bool(getattr(deps, "tray_available", False)), ) @ui.page("/problems") def _problems() -> Any: deps = _deps() findings = _safe_audit(deps) return problems_page.render_problems_page( findings=findings, last_audit_at=getattr(deps, "last_audit_at", None), ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _lims_credential_handlers( deps: Any, ui: Any ) -> tuple[Callable[[str], None], Callable[[], None]]: """Build the LIMS-password Save / Clear handlers for the settings dialog. Credentials are independent of the config Save (Frontend Spec §7.3): these write straight to the OS keyring via ``deps.keyring_store`` at click time, under the ``(exlab-wizard, lims)`` pair. A missing keyring store (best-effort construction failed at tray boot) or a backend error surfaces as a negative toast instead of crashing the page. """ keyring_store = getattr(deps, "keyring_store", None) if deps is not None else None def _on_save(value: str) -> None: if keyring_store is None: _show_toast( ui, "Cannot save the password: the OS keyring is unavailable", positive=False ) return try: keyring_store.set_password(username=KEYRING_USERNAME_LIMS, password=value) except Exception as exc: _log.exception("LIMS keyring set_password failed") _show_toast(ui, f"Could not save the LIMS password: {exc}", positive=False) return # The credential field re-seeds its "Set / Not set" status from # ``deps.keyring_password_present`` on the next render, and the # §4.9 setup gate reads the same flag. It is computed once at # tray boot, so flip it here -- otherwise a freshly saved # password would still read as absent until the next tray launch. if deps is not None: deps.keyring_password_present = True _show_toast(ui, "LIMS password saved to the OS keyring", positive=True) def _on_clear() -> None: if keyring_store is None: _show_toast( ui, "Cannot clear the password: the OS keyring is unavailable", positive=False ) return try: keyring_store.delete_password(username=KEYRING_USERNAME_LIMS) except Exception as exc: _log.exception("LIMS keyring delete_password failed") _show_toast(ui, f"Could not clear the LIMS password: {exc}", positive=False) return # Mirror of the Save path: clearing the password makes the slot # incomplete again, so drop the boot-time flag in step. if deps is not None: deps.keyring_password_present = False _show_toast(ui, "LIMS password removed from the OS keyring", positive=True) return _on_save, _on_clear async def _list_nas_remotes(config_path: str) -> tuple[str, ...]: """Return the rclone remotes (each incl. trailing ``":"``) for ``config_path``. Backs the Settings "NAS Remote" dropdown's Refresh affordance: re-runs ``rclone listremotes`` against the *typed* ``--config`` path so a just-edited config path is reflected without a tray relaunch. Any failure (no binary, unreadable config) degrades to ``()`` so the dropdown simply shows nothing new rather than raising. """ from exlab_wizard.sync.transports.rclone import RcloneDriver try: return await RcloneDriver(config_path=config_path or None).listremotes() except Exception: return () async def _nas_test_connection(remote: str, config_path: str) -> Any: """Probe the *typed* rclone NAS remote and adapt it for the inline panel. rclone.conf NAS-sync migration. The Settings "NAS Remote" section's Test-connection button validates the values currently in the form -- ``remote`` (the rclone remote name) and the optional ``config_path`` (``rclone --config <path>``) -- *before* the operator saves, so a fresh selection can be tested immediately. It runs ``rclone about <remote>:`` through the driver and maps the :class:`AboutResult` to a :class:`TestConnectionResult`. This is intentionally independent of ``deps.equipment_probe`` (which the ``POST /setup/test-equipment`` endpoint still uses against the *saved* config): the UI probe must reflect unsaved edits. """ import json import time from exlab_wizard.sync.transports.rclone import RcloneDriver from exlab_wizard.ui.components.test_connection_panel import TestConnectionResult if not remote: return TestConnectionResult( success=False, headline="Connection failed", detail="no NAS remote configured", raw="", ) driver = RcloneDriver(config_path=config_path or None) started = time.monotonic() try: about = await driver.about(f"{remote}:") except Exception as exc: return TestConnectionResult( success=False, headline="Connection failed", detail=str(exc), raw=str(exc) ) latency_ms = int((time.monotonic() - started) * 1000) payload = {"ok": about.ok, "reason": about.reason, "latency_ms": latency_ms, **about.info} raw = json.dumps(payload, indent=2, sort_keys=True) if about.ok: return TestConnectionResult( success=True, headline="Connected", detail=f"reachable ({latency_ms} ms)", raw=raw ) return TestConnectionResult( success=False, headline="Connection failed", detail=str(about.reason) if about.reason else "connection failed", raw=raw, ) def _persist_config(deps: Any, updated: Any, ui: Any) -> bool: """Write ``updated`` via ``deps.save_config`` and hot-reload components. Returns ``True`` on success. On a write failure a negative toast is shown and the function returns ``False`` so the caller leaves the operator on the settings page to retry. The disk write is the success boundary: once it lands, the new config is pushed into the running components via ``apply_live_config`` so the change takes effect without a tray relaunch. A hiccup in that live push is logged but does not fail the save -- the config is already persisted and the in-memory copy is kept in step. """ saver = getattr(deps, "save_config", None) if deps is not None else None if saver is None: _show_toast(ui, "Cannot save: no config writer is available", positive=False) return False try: result = saver(updated) if hasattr(result, "__await__"): # Production wires a synchronous saver; an awaitable here # would silently no-op, so surface it rather than swallow. _log.warning("save_config returned an awaitable; a sync saver is expected") except Exception as exc: _log.exception("save_config failed") _show_toast(ui, f"Save failed: {exc}", positive=False) return False _ensure_app_dirs(updated) _ensure_staging_root(updated, ui) if deps is not None: _apply_live_config(deps, updated) return True def _ensure_app_dirs(updated: Any) -> None: """Create the app root and its derived subdirs after a save. Materializes ``app_root`` plus ``data/``, ``templates/`` and ``plugins/`` so a fresh install's working tree exists the moment paths are saved. A creation failure is non-fatal (the config is already persisted; the setup writability gate surfaces an unusable root), and a non-``Config`` value is a defensive no-op. """ paths = getattr(updated, "paths", None) if paths is None or not getattr(paths, "app_root", ""): return from exlab_wizard.paths import ensure_app_dirs try: ensure_app_dirs(updated) except OSError: _log.exception("failed to create app dirs") def _ensure_staging_root(updated: Any, ui: Any) -> None: """Create the staging directory when the operator saved a non-empty path. ``staging_root`` is opt-in: a blank value means this device is not a staging PC, so nothing is created. A non-empty path is created here -- the operator specifying and saving it is the only trigger, so no ``/staging`` (or any staging directory) is ever made implicitly. A creation failure is non-fatal: the config is already persisted and the validator flags an inaccessible root on the next audit, so we only warn. ``updated`` is read defensively so a non-``Config`` value (e.g. a test sentinel) is a no-op rather than an ``AttributeError``. """ orch = getattr(updated, "orchestrator", None) staging = getattr(orch, "staging_root", "") if not staging: return from exlab_wizard.paths import ensure_dir try: ensure_dir(Path(staging)) except OSError as exc: _log.exception("failed to create staging_root") _show_toast(ui, f"Couldn't create staging directory: {exc}", positive=False) def _apply_live_config(deps: Any, updated: Any) -> None: """Push ``updated`` into the running components (no tray relaunch). Wraps :func:`exlab_wizard.tray.dependencies.apply_live_config` (imported lazily to avoid a tray<->api import cycle). The coordinator is already best-effort per component, so reaching the ``except`` is unexpected; it still keeps ``deps.config`` in step so ``GET /config`` is correct. """ try: from exlab_wizard.tray.dependencies import apply_live_config apply_live_config(deps, updated) except Exception: _log.exception("live config reload failed after save") deps.config = updated def _nas_remote_missing(deps: Any, config: Any) -> bool: """True when nas-mode equipment exist but the ``nas:`` remote is unusable. rclone.conf NAS-sync migration. Drives the Settings page's NAS-remote section visibility via :func:`_missing_setup_sections` so the setup-incomplete banner auto-selects it. The remote is "missing" when at least one device syncs directly to the NAS AND either no ``nas.remote`` is configured or that remote is not present in the operator's ``rclone.conf`` (the same gate the §4.9 setup evaluator, ``INCOMPLETE_NO_NAS_REMOTE``, keys on). """ from exlab_wizard.api._dependencies import nas_remote_available from exlab_wizard.constants import SyncMode has_nas_equipment = any( eq.sync_mode == SyncMode.NAS for eq in getattr(config, "equipment", ()) or () ) if not has_nas_equipment: return False nas = getattr(config, "nas", None) remote = getattr(nas, "remote", "") if nas is not None else "" return not remote or not nas_remote_available(deps, remote) def _is_setup_ready(deps: Any) -> bool: """Return True when the §4.9 setup state is ``READY``. Delegates to :func:`api.setup.compute_setup_state` -- the single source of truth that ``GET /api/v1/setup/status``, the route gate, and the banner subline (:func:`_setup_next_action`) all consult -- so the main-page setup-incomplete banner agrees with the API's verdict on every gate (paths, orchestrator, equipment, NAS credentials, and *both* LIMS branches). An earlier hand-rolled mirror checked only the LIMS *keyring* branch, so it kept the banner up for an otherwise-ready install whose LIMS slot is satisfied by ``offline_catalogue_path`` rather than a stored password -- a disconnected-workstation setup read as perpetually incomplete even though ``/setup/status`` reported ``ready`` (rclone-only migration follow-up, 2026-05-28). Best-effort: any evaluation failure degrades to "not ready" so a half-wired backend keeps the operator on the onboarding path rather than leaking a stack trace into the index route. """ if deps is None or getattr(deps, "config", None) is None: return False try: from exlab_wizard.api.setup import compute_setup_state return compute_setup_state(deps) is SetupState.READY except Exception as exc: _log.warning("setup-readiness computation failed: %s", exc) return False def _apply_autostart(deps: Any, enabled: bool) -> bool | None: """Register / unregister platform autostart; return the real post-op state. Returns ``deps.autostart_toggle``'s ``is_registered()`` result so callers (Settings -> Application) can reflect / revert the checkbox to reality; ``None`` when no toggle is wired or the op raised. """ if deps is None: return None toggle: Callable[[bool], Any] | None = getattr(deps, "autostart_toggle", None) if toggle is None: return None try: return bool(toggle(enabled)) except Exception as exc: _log.warning("autostart toggle failed: %s", exc) return None def _build_main_state( deps: Any, *, selected_node: str | None = None, node_kind: str | None = None, is_received: bool = False, right_pane_collapsed: bool = False, selected_file_path: str | None = None, selected_file: dict[str, Any] | None = None, search_query: str = "", density: str = "", ) -> Any: from exlab_wizard.ui.components.status_bar_segment import derive_footer_segment_states from exlab_wizard.ui.pages import main as main_page # Redesign §3.1: orchestrator pipeline is always active; the staging # surface always renders, so MainPageState.orchestrator_enabled keeps # its True default. Folder-feed path mirrors the selected node so the # centre pane shows the right folder. ops_count, ops_input_required, ops_active = _operation_counts(deps) # Real Problems counts from the 30 s background audit (T6 / §B5). problems_hard = int(getattr(deps, "last_audit_hard", 0) or 0) # Footer status segments (Phase 5 / §3.5.5): Validator warns on a hard # finding; LIMS goes danger when the endpoint is unreachable. ``lims_reachable`` # defaults True so a half-wired backend doesn't false-alarm; Staging has no # cheap cached count yet (a per-render list_staged_runs scan would be I/O on # the render path) so it stays NORMAL. lims_reachable = bool(getattr(deps, "lims_reachable", True)) if deps is not None else False footer_segments = derive_footer_segment_states( problems_count_hard=problems_hard, lims_reachable=lims_reachable, ) return main_page.MainPageState( setup_incomplete=not _is_setup_ready(deps), setup_next_action=_setup_next_action(deps), selected_node=selected_node, selected_node_kind=node_kind, selected_node_is_received=is_received, right_pane_collapsed=right_pane_collapsed, folder_feed_path=selected_node, selected_file_path=selected_file_path, selected_file=selected_file, search_query=search_query, density=density, operations_count=ops_count, operations_input_required=ops_input_required, creation_in_flight=ops_active > 0, problems_count_hard=problems_hard, problems_count_soft=int(getattr(deps, "last_audit_soft", 0) or 0), validator_state=footer_segments.validator, lims_state=footer_segments.lims, staging_state=footer_segments.staging, ) def _panel_sessions(deps: Any) -> list[tuple[str, Any]]: """Return the (session_id, session) pairs the Operations panel shows. The §9.5 membership rule lives here only: everything except the terminal ``DONE`` / ``ABORTED`` (``FAILED`` stays so a recent failure is visible). Shared by :func:`_operation_counts` and :func:`_build_operation_rows` so the rule can't drift. """ controller = getattr(deps, "controller", None) if deps is not None else None store = getattr(controller, "session_store", None) if controller is not None else None if store is None: return [] from exlab_wizard.controller import on_operations_panel return [(sid, session) for sid, session in store.iter_sorted() if on_operations_panel(session)] def _operation_counts(deps: Any) -> tuple[int, int, int]: """Return ``(panel_count, input_required, active)`` operation counts. ``panel_count`` is the §9.5 panel size (see :func:`_panel_sessions`). ``input_required`` counts suspended sessions awaiting a plugin answer (Frontend §9.5 / §3.5.5). ``active`` counts strictly non-terminal sessions and gates the §9.6 creation-button lock (``FAILED`` is terminal, so it sits in the panel but does not lock creation). """ from exlab_wizard.controller import SessionState panel_rows = _panel_sessions(deps) input_required = sum(1 for _sid, s in panel_rows if s.state is SessionState.INPUT_REQUIRED) active = sum(1 for _sid, s in panel_rows if not s.is_terminal()) return (len(panel_rows), input_required, active) def _setup_next_action(deps: Any) -> str | None: """Return the §4.9.3 next-action string for the banner subline. Unlike :func:`_is_setup_ready` (a deliberately narrow LIMS-only readiness mirror), this consults the real evaluator via ``compute_setup_state`` so the banner names the actual first-failing gate -- notably the NAS-credentials gate added by the rclone-only migration (2026-05-26). Best-effort: any failure yields ``None`` so the banner falls back to its generic subline. """ if deps is None: return None try: from exlab_wizard.api.setup import compute_setup_state from exlab_wizard.paths import setup_state_next_action action = setup_state_next_action(compute_setup_state(deps)) return action.value if action is not None else None except Exception as exc: _log.warning("setup next-action computation failed: %s", exc) return None def _build_main_query( selected: str, right_pane: str, *, file: str = "", q: str = "", density: str = "", ) -> str: """Compose the ``?selected=...&right_pane=...`` query string for /main. Omits each param when empty so the URL stays clean for default state. Used by every callback that re-navigates to /main with mutated state. Values are URL-encoded so node ids with spaces (project names like ``Cortex Q3 Pilot``) or other special characters survive the round trip back through the FastAPI query parser. The path separator ``/`` is intentionally preserved (``safe="/"``) so the encoded id stays human-readable in the address bar. Phase 4 (Option B / OQ-1/A) adds three optional params carried on the same URL/navigate model rather than a second (refreshable) paradigm: ``file`` (selected file/folder path in the centre list), ``q`` (search query), and ``density`` (file-list row density). Filesystem paths in ``file`` are URL-encoded (spaces / unicode) like ``selected``. """ from urllib.parse import quote parts: list[str] = [] if selected: parts.append(f"selected={quote(selected, safe='/')}") if right_pane: parts.append(f"right_pane={quote(right_pane, safe='/')}") if file: parts.append(f"file={quote(file, safe='/')}") if q: parts.append(f"q={quote(q, safe='')}") if density: parts.append(f"density={quote(density, safe='')}") return ("?" + "&".join(parts)) if parts else "" def _classify_node(node_id: str | None, hierarchy: dict[Any, Any]) -> tuple[str | None, bool]: """Map a selected node id to ``(kind, is_received)``. Mirrors the shape classifier in ``tests/e2e/_test_app.py``: matches by node-id prefix against the hierarchy keys (equipment ids), then checks the path depth to discriminate equipment / project / run. Returns ``(None, False)`` for an unselected node or for a node id whose root equipment isn't in the hierarchy (defensive: the URL came from elsewhere, or the config changed since the link was captured). """ if not node_id: return None, False from exlab_wizard.ui.components import tree as ui_tree owned_ids: set[str] = set() relay_ids: set[str] = set() for equipment_node in hierarchy: if not isinstance(equipment_node, ui_tree.EquipmentNode): continue if equipment_node.relay: relay_ids.add(equipment_node.equipment_id) else: owned_ids.add(equipment_node.equipment_id) root = node_id.split("/", 1)[0] if root not in owned_ids and root not in relay_ids: # The id's root equipment isn't in this device's tree -- treat # as unselected so the page doesn't render half-baked state. return None, False is_received = root in relay_ids if "/" not in node_id: kind = "received_equipment" if is_received else "equipment" elif "TestRun_" in node_id or "/Run_" in node_id: kind = "run" else: kind = "project" return kind, is_received def _build_metadata_payload( node_id: str | None, node_kind: str | None, deps: Any, ) -> dict[str, Any]: """Build the metadata-pane payload for the selected node, by kind. Returns ``{}`` on any failure -- the metadata pane already tolerates a missing payload and renders the empty state. The shape per kind matches ``src/exlab_wizard/ui/components/metadata_pane.py``. """ if not node_id or not node_kind: return {} config = getattr(deps, "config", None) if deps is not None else None if config is None: return {} try: if node_kind == "equipment": return _metadata_for_owned_equipment(node_id, config) if node_kind == "received_equipment": return _metadata_for_relay_equipment(node_id, config) if node_kind == "project": return _metadata_for_project(node_id, config) if node_kind == "run": return _metadata_for_run(node_id) return {} except Exception as exc: _log.warning("metadata payload build failed for %s (%s): %s", node_id, node_kind, exc) return {} def _metadata_for_owned_equipment(node_id: str, config: Any) -> dict[str, Any]: """Project equipment-config fields into the owned-equipment payload.""" for entry in getattr(config, "equipment", []): if entry.id != node_id: continue # Equipment data dir is derived from the single app root # (``<data_root>/<equipment_id>``); equipment no longer stores its own # local_root. local_root = str(Path(config.paths.local_root) / entry.id) return { "id": entry.id, "label": entry.label or entry.id, "sync_mode": str(getattr(entry, "sync_mode", "")) or "nas", "local_root": local_root, "nas_root": entry.nas_root or "", } return {} def _metadata_for_relay_equipment(node_id: str, config: Any) -> dict[str, Any]: """Return the relay-equipment payload (label, source_host).""" from exlab_wizard.api.routers import browse as _browse for relay in _browse.build_received_equipment_nodes(config): if relay.id == node_id: return { "id": relay.id, "label": relay.label or relay.id, "source_host": "", # populated by the relay producer's creation.json } return {"id": node_id, "label": node_id, "source_host": ""} def _metadata_for_project(node_id: str, config: Any) -> dict[str, Any]: """Derive a project payload from the on-disk project directory. ``node_id`` is ``<equipment_id>/<project_name>``. Walks the project dir to count runs / test runs and reads the optional README.md for the objective summary; falls back to empty fields when the dir or README isn't present. """ from exlab_wizard.constants import README_FILE_NAME, RUN_DIR_PREFIX, TEST_RUN_DIR_PREFIX parts = node_id.split("/", 1) if len(parts) != 2: return {} equipment_id, project_name = parts local_root = Path(getattr(config.paths, "local_root", "") or "") project_dir = local_root / equipment_id / project_name run_count = _count_dir_children(project_dir / "Runs", RUN_DIR_PREFIX) test_run_count = _count_dir_children(project_dir / "TestRuns", TEST_RUN_DIR_PREFIX) objective = "" try: objective = ( (project_dir / README_FILE_NAME).read_text(encoding="utf-8").splitlines()[0][:120] ) except (FileNotFoundError, OSError, IndexError): objective = "" return { "name": project_name, "short_id": project_name, "objective": objective, "run_count": run_count, "test_run_count": test_run_count, } def _count_dir_children(parent: Path, prefix: str) -> int: """Count immediate child directories of ``parent`` whose names start with ``prefix``. Returns 0 when ``parent`` is missing / unreadable. """ try: with os.scandir(parent) as iterator: return sum( 1 for entry in iterator if entry.name.startswith(prefix) and entry.is_dir(follow_symlinks=False) ) except (FileNotFoundError, NotADirectoryError, PermissionError): return 0 def _metadata_for_run(node_id: str) -> dict[str, Any]: """Decode the run's creation.json into the metadata-pane payload. Returns ``{}`` on any parse error so the pane shows the empty state instead of crashing. """ import msgspec from exlab_wizard.api.schemas import CreationJson from exlab_wizard.io import read_msgspec_json from exlab_wizard.paths import creation_json_path run_path = Path(node_id) cache_path = creation_json_path(run_path) if not cache_path.exists(): return {"path": str(run_path), "name": run_path.name} try: payload = read_msgspec_json(cache_path, CreationJson) except (msgspec.DecodeError, msgspec.ValidationError): return {"path": str(run_path), "name": run_path.name} return { "label": payload.lims_project.name_at_creation, "name": run_path.name, "run_kind": str(payload.run_kind), "operator": payload.created_by or "", "objective": "", # creation.json doesn't carry a separate objective today "template": payload.template.name if payload.template else "", "created_at": payload.created_at or "", "lims_project": payload.lims_project.short_id, "sync_status": payload.sync_status or "", "path": str(run_path), } # --------------------------------------------------------------------------- # Selection -> metadata payload (Phase 4, Option B / spec §4.3) # --------------------------------------------------------------------------- def _build_selected_file( file_path: str | None, feed_entries: list[Any], deps: Any, ) -> dict[str, Any] | None: """Resolve a centre-list selection (file or folder) to a render payload. Phase 4 / Option B (spec §4.3). ``file_path`` is the ``?file=`` query param -- the path the operator single-clicked in the Files pane. It is matched by path against the in-memory folder-feed entries (already fetched for the current node, so a file needs no extra I/O). A match that is a directory delegates to :func:`_build_selected_folder` for the one-level aggregate scan; a file match projects the feed entry's fields into a file-card payload. Returns ``None`` when ``file_path`` is empty or matches no current entry -- e.g. the file was removed since the click -- so the metadata pane shows no sub-card rather than an error (spec §7). File payload shape:: {"kind": "file", "name", "path", "size", "modified", "sync_status", "tombstone"} ``size`` is pre-formatted via :func:`format_bytes`; a tombstone ("On NAS") row has no on-disk copy, so its ``size`` is ``None`` (spec §4.5). """ if not file_path: return None match = next((e for e in feed_entries if getattr(e, "path", None) == file_path), None) if match is None: return None if getattr(match, "is_dir", False): return _build_selected_folder(match.name, match.path, deps) from exlab_wizard.ui.pages.staging import format_bytes size_bytes = getattr(match, "size_bytes", None) tombstone = bool(getattr(match, "tombstone", False)) return { "kind": "file", "name": match.name, "path": match.path, "size": (None if tombstone or size_bytes is None else format_bytes(int(size_bytes))), "modified": getattr(match, "modified_iso", None), "sync_status": getattr(match, "sync_status", None), "tombstone": tombstone, } def _build_selected_folder( name: str, path: str, deps: Any, ) -> dict[str, Any]: """Aggregate a one-level folder scan into a folder-card payload (OQ-4/B). Phase 4 / Option B (spec §4.3, §4.6). A folder selected in the centre list is summarised by a single-level :func:`scan_folder_sync`: its immediate ``item_count`` and a worst-of ``sync_rollup`` over the children's per-file sync states. No total size is computed -- the recursive walk is deferred (spec §11). A scan failure degrades to ``item_count=None`` + a neutral (``None``) rollup rather than raising, so a transient permission / vanished-folder error still renders a usable card (spec §7). Folder payload shape:: {"kind": "folder", "name", "path", "item_count", "rollup"} """ from exlab_wizard.api.routers import browse as _browse from exlab_wizard.ui.components.sync_rollup import sync_rollup config = getattr(deps, "config", None) if deps is not None else None item_count: int | None rollup: str | None try: response = _browse.scan_folder_sync(path, config) entries = list(getattr(response, "entries", []) or []) item_count = len(entries) rollup = sync_rollup(getattr(entry, "sync_status", None) for entry in entries) except Exception as exc: _log.warning("folder aggregate scan failed for %s: %s", path, exc) item_count = None rollup = None return { "kind": "folder", "name": name, "path": path, "item_count": item_count, "rollup": rollup, } def _drive_folder_feed(app: Any, deps: Any, selected_path: str | None) -> list[Any]: """Mount / rebind the per-tab FolderFeed and return current entries. Uses ``app.storage.tab`` so one feed instance lives across navigations within a tab. Switching paths calls ``feed.start(new_path)`` which cancels the prior poll loop and starts a fresh one. Returns the most recent payload as a list of ``FileListEntry`` (empty when the feed hasn't ticked yet for this path). """ from exlab_wizard.ui.client import folder_feed, refresh_coordinator from exlab_wizard.ui.components.file_list import FileListEntry if selected_path is None: return [] try: tab_storage: Any = app.storage.tab except Exception: tab_storage = None coord_key = "folder_feed_coord" feed_key = "folder_feed" coord = None feed = None if tab_storage is not None: coord = tab_storage.get(coord_key) feed = tab_storage.get(feed_key) if coord is None: coord = refresh_coordinator.RefreshCoordinator() if tab_storage is not None: tab_storage[coord_key] = coord if feed is None: feed = folder_feed.FolderFeed( fetch=lambda p: _fetch_folder_async(deps, p, coord), ) if tab_storage is not None: tab_storage[feed_key] = feed # Rebind to the current selection if it changed; FolderFeed.start # is idempotent for the same path. Reference is kept on tab storage # so it isn't garbage-collected mid-poll. if feed.state.path != selected_path: _spawn_background(feed.start(selected_path)) payload = feed.state.last_payload if payload is None: return [] # Payload is a FolderResponse (Pydantic) from scan_folder_sync. entries: list[Any] = [] for entry in getattr(payload, "entries", []) or []: entries.append( FileListEntry( name=entry.name, path=entry.path, is_dir=entry.is_dir, size_bytes=entry.size_bytes, modified_iso=entry.modified_iso, sync_status=entry.sync_status, keep_local=getattr(entry, "keep_local", False), tombstone=getattr(entry, "tombstone", False), ) ) return entries async def _fetch_folder_async(deps: Any, path: str, coord: Any) -> Any: """FolderFeed fetch hook. Skips when the tree just walked. Runs the synchronous ``scan_folder_sync`` helper in a thread to keep the asyncio loop responsive (matches the codebase's existing cache/equipment.py convention). Returns the raw FolderResponse; transient HTTPException / OSError are swallowed and surface as ``None`` so the feed keeps polling. """ if coord is not None and coord.should_skip_folder(): return None from exlab_wizard.api.routers import browse as _browse config = getattr(deps, "config", None) if deps is not None else None try: result = await asyncio.to_thread(_browse.scan_folder_sync, path, config) except Exception as exc: _log.debug("folder feed scan failed for %s: %s", path, exc) return None if coord is not None: coord.record_folder_refresh() return result def _refresh_selected_folder(app: Any, deps: Any, selected_path: str | None) -> None: """Force a fresh single-folder scan and prime the feed payload (OQ-1/A). The Files-pane refresh button's mitigation for the navigate-per-click model (spec §4.6 / OQ-1/A): rather than wait for the folder feed's next poll tick, scan the current folder synchronously now and write the result onto the per-tab feed's ``last_payload`` so the immediate re-navigation renders fresh contents. Distinct from the toolbar's "Refresh everything" -- this re-scans only the open folder. A scan failure is swallowed to a WARN: the existing payload simply stays until the next poll (spec §7). A no-op when nothing is selected or the per-tab feed hasn't been mounted yet. """ if selected_path is None: return from exlab_wizard.api.routers import browse as _browse config = getattr(deps, "config", None) if deps is not None else None try: payload = _browse.scan_folder_sync(selected_path, config) except Exception as exc: _log.warning("per-folder refresh scan failed for %s: %s", selected_path, exc) return try: tab_storage: Any = app.storage.tab except Exception: tab_storage = None feed = tab_storage.get("folder_feed") if tab_storage is not None else None if feed is not None and getattr(feed, "state", None) is not None: feed.state.last_payload = payload # A folder walk just happened, so record it on the coordinator -- the # same bookkeeping _fetch_folder_async does after its scan. This keeps # the manual refresh inside the coalescing window (should_skip_tree), # so an immediately-following tree poll won't redundantly re-walk. coord = tab_storage.get("folder_feed_coord") if tab_storage is not None else None if coord is not None: coord.record_folder_refresh() def _run_staging_action(deps: Any, path: str, action: str, ui: Any) -> None: """Dispatch a per-run context action to its backend surface. Mirrors :func:`api.routers.staging.post_force_sync` / :func:`api.routers.staging.post_clear` but invokes the underlying primitives directly from the mount so the action stays in-process (no HTTP round trip from the same Python interpreter). """ from exlab_wizard.ui.components.tree_context_menu import ( RUN_CONTEXT_CLEAR_VERIFIED, RUN_CONTEXT_FORCE_SYNC, RUN_CONTEXT_VIEW_LOG, ) config = getattr(deps, "config", None) if deps is not None else None if config is None: _show_toast(ui, "Run action unavailable: no config", positive=False) return run_path = Path(path) if action == RUN_CONTEXT_FORCE_SYNC: nas_sync = getattr(deps, "nas_sync", None) if deps is not None else None if nas_sync is None: _show_toast(ui, "Force-sync unavailable: NAS sync not wired", positive=False) return async def _do_enqueue() -> None: try: await nas_sync.enqueue(run_path) except Exception as exc: _log.exception("force-sync via mount failed") _show_toast(ui, f"Force-sync failed: {exc}", positive=False) return _show_toast(ui, f"Force-sync queued for {run_path.name}", positive=True) _spawn_background(_do_enqueue()) return if action == RUN_CONTEXT_CLEAR_VERIFIED: async def _do_clear() -> None: try: files, _bytes = await asyncio.to_thread(clear_run_dir, run_path) except Exception as exc: _log.exception("per-run clear failed") _show_toast(ui, f"Clear failed: {exc}", positive=False) return if files == 0: _show_toast(ui, f"{run_path.name} already cleared", positive=True) else: _show_toast(ui, f"Cleared {files} file(s) from {run_path.name}", positive=True) _spawn_background(_do_clear()) return if action == RUN_CONTEXT_VIEW_LOG: _open_log_dialog(deps, run_path, ui) return _show_toast(ui, f"Unknown run action: {action}", positive=False) def _file_context_action( deps: Any, entry: Any, action: str, ui: Any, *, on_done: Callable[[], None] | None = None, ) -> None: """Handle ``Open in OS`` / ``Copy path`` / ``Keep local`` file actions.""" from exlab_wizard.ui.components.file_list import ( FILE_CONTEXT_COPY_PATH, FILE_CONTEXT_KEEP_LOCAL, FILE_CONTEXT_OPEN, ) path = str(getattr(entry, "path", "")) if not path: _show_toast(ui, "No path on file entry", positive=False) return if action == FILE_CONTEXT_OPEN: if _open_in_os(path): _show_toast(ui, f"Opening {Path(path).name}", positive=True) else: _show_toast(ui, "Could not open file in OS", positive=False) return if action == FILE_CONTEXT_COPY_PATH: try: ui.clipboard.write(path) except Exception as exc: _log.warning("clipboard.write failed: %s", exc) _show_toast(ui, "Clipboard unavailable", positive=False) return _show_toast(ui, "Path copied to clipboard", positive=True) return if action == FILE_CONTEXT_KEEP_LOCAL: _toggle_keep_local(deps, entry, ui, on_done=on_done) return _show_toast(ui, f"Unknown file action: {action}", positive=False) def _toggle_keep_local( deps: Any, entry: Any, ui: Any, *, on_done: Callable[[], None] | None = None, ) -> None: """Flip a file's ``keep_local`` flag via the orchestrator's writer. Operator-free per-file NAS sync design (2026-05-21): ``sync_state.json`` has a single writer -- the orchestrator's :class:`SyncStateWriter` -- so the GUI never writes the file directly. The mount calls ``set_keep_local`` in-process (matching the per-run staging actions), which is exactly what the ``POST /staging/{run}/keep-local`` endpoint does. The run root is resolved by walking up to the nearest ``creation.json`` cache. """ from exlab_wizard.api.routers.browse import _find_run_root, _run_relative_posix writer = getattr(deps, "sync_state_writer", None) if deps is not None else None if writer is None: _show_toast(ui, "Keep-local unavailable: sync-state writer not wired", positive=False) return path = Path(str(getattr(entry, "path", ""))) run_root = _find_run_root(path.parent) if run_root is None: _show_toast(ui, "Keep-local unavailable: file is not inside a run", positive=False) return rel = _run_relative_posix(run_root, path) if rel is None: _show_toast(ui, "Keep-local unavailable: could not resolve file path", positive=False) return new_value = not bool(getattr(entry, "keep_local", False)) async def _do_toggle() -> None: try: await writer.set_keep_local(run_root, rel, new_value) except Exception as exc: _log.exception("keep-local toggle failed") _show_toast(ui, f"Keep-local failed: {exc}", positive=False) return verb = "kept local" if new_value else "no longer kept local" _show_toast(ui, f"{path.name} {verb}", positive=True) if on_done is not None: on_done() _spawn_background(_do_toggle()) def _open_in_os(path: str) -> bool: """Launch the host OS's default opener for ``path``. Returns False on platforms / failures we can't handle so the caller can surface a negative toast. The launch is fire-and-forget so the UI doesn't block waiting for the external app. """ import subprocess import sys try: if sys.platform.startswith("linux"): subprocess.Popen(["xdg-open", path]) return True if sys.platform == "darwin": subprocess.Popen(["open", path]) return True if sys.platform == "win32": import os as _os _os.startfile(path) # type: ignore[attr-defined] return True except Exception as exc: _log.warning("open_in_os failed for %s: %s", path, exc) return False return False def _build_operation_rows(deps: Any) -> list[Any]: """Build the Operations-panel rows from the live session store (T3). Uses the shared §9.5 membership rule (:func:`_panel_sessions`): terminal ``DONE`` / ``ABORTED`` sessions fall off; ``FAILED`` stays so a recent failure is visible. """ from exlab_wizard.ui.components.operations_modal import OperationRow return [OperationRow.from_session(sid, session) for sid, session in _panel_sessions(deps)] def _open_operations_modal(deps: Any, ui: Any) -> None: """Open the in-flight Operations panel (Frontend §9.5). Builds a fresh snapshot on each open (true auto-refresh is the wizard's live stream, T2). Row actions dispatch to resume / cancel / details. """ from exlab_wizard.ui.components.operations_modal import operations_modal controller = getattr(deps, "controller", None) if deps is not None else None if controller is None: _show_toast(ui, "Operations unavailable: controller not initialized", positive=False) return rows = _build_operation_rows(deps) dialog = operations_modal( rows, on_resume=lambda oid: _resume_operation(deps, oid, ui), on_cancel=lambda oid: _cancel_operation(deps, oid, ui), on_view_log=lambda oid: _open_operation_details(deps, oid, ui), ) opener = getattr(dialog, "open", None) if callable(opener): opener() def _open_operation_details(deps: Any, session_id: str, ui: Any) -> None: """Show a lightweight details/"log" dialog for one in-flight operation. The §9.5 "View log" action: surfaces the session's current state plus any suspend reason or error. (The NAS-sync run log is a separate, post-creation concern handled by :func:`_open_log_dialog`.) """ controller = getattr(deps, "controller", None) if deps is not None else None store = getattr(controller, "session_store", None) if controller is not None else None session = store.get(session_id) if store is not None else None if session is None: _show_toast(ui, "Operation not found", positive=False) return state_val = getattr(session.state, "value", str(session.state)) dialog = ui.dialog() with ( dialog, ui.card().props('data-testid="operation-log-dialog"').style("min-width: 480px;"), ): ui.label(f"Operation {session_id}").style("font-weight: 600;") ui.label(f"State: {state_val}").style("color: var(--color-muted);") pending = getattr(session, "pending_input", None) if pending: ui.label(f"Awaiting input: {pending.get('reason', '')}").style( "font-family: var(--font-mono); font-size: 0.85em;" ) error = getattr(session, "error", None) if error: ui.label(f"Error: {error.get('message', error.get('code', ''))}").style( "color: var(--color-danger); font-family: var(--font-mono); font-size: 0.85em;" ) dialog.open() def _resume_operation(deps: Any, session_id: str, ui: Any) -> None: """Resume a suspended session by re-opening its §9.1 input dialog (T5). Reads the parked ``pending_input`` (plugin / reason / fields) off the session and re-presents the escalation dialog; Submit resumes the pipeline with the answers. """ controller = getattr(deps, "controller", None) if deps is not None else None store = getattr(controller, "session_store", None) if controller is not None else None session = store.get(session_id) if store is not None else None pending = getattr(session, "pending_input", None) if session is not None else None if controller is None or not pending: _show_toast(ui, "Nothing to resume: the operation is not awaiting input", positive=False) return _open_input_required_dialog( controller, session_id, ui, plugin=pending.get("plugin", ""), reason=pending.get("reason", ""), fields=pending.get("fields") or [], ) def _open_input_required_dialog( controller: Any, session_id: str, ui: Any, *, plugin: str, reason: str, fields: list[Any], ) -> Any: """Open the §9.1 escalation dialog; Submit resumes, Cancel confirms (T5). Submit calls ``controller.resume(session_id, values)`` -- the suspended pipeline wakes with the answers. ``resume`` raises on an unknown session or a stale (non-``INPUT_REQUIRED``) state; a plugin re-rejecting the values simply re-emits ``input_required`` (the consumer re-opens this dialog). Both are surfaced to the operator. Cancel routes through the §9.4 cancel dialog. Returns the dialog so the caller can force-close it on a terminal frame. """ from exlab_wizard.ui.components.input_required_dialog import input_required_dialog def _on_submit(values: dict[str, Any]) -> None: async def _run() -> None: try: await controller.resume(session_id, values) except Exception as exc: _show_toast(ui, f"Could not submit input: {exc}", positive=False) _spawn_background(_run()) def _on_cancel() -> None: _cancel_session(controller, session_id, ui) dialog = input_required_dialog( plugin=plugin, reason=reason, fields=fields, on_submit=_on_submit, on_cancel=_on_cancel, ) opener = getattr(dialog, "open", None) if callable(opener): opener() return dialog def _cancel_operation(deps: Any, session_id: str, ui: Any) -> None: """Resolve the controller off ``deps`` and open the §9.4 cancel dialog.""" controller = getattr(deps, "controller", None) if deps is not None else None if controller is None: _show_toast(ui, "Cancel unavailable: controller not initialized", positive=False) return _cancel_session(controller, session_id, ui) def _cancel_session(controller: Any, session_id: str, ui: Any) -> None: """Cancel an in-flight session via the §9.4 Discard / Keep dialog (T4). The operator chooses whether to discard the partially-created files (``discard_files=True`` -> ``shutil.rmtree`` of the partial dir) or keep them in place as an orphan. ``controller.cancel`` is a no-op on an already-terminal session; any error is surfaced as a toast. """ dialog = ui.dialog() def _choose(discard_files: bool) -> None: dialog.close() async def _run() -> None: try: await controller.cancel(session_id, discard_files=discard_files) _show_toast(ui, "Operation cancelled", positive=True) except Exception as exc: _show_toast(ui, f"Cancel failed: {exc}", positive=False) _spawn_background(_run()) with ( dialog, ui.card().props('data-testid="cancel-confirm-dialog"').style("min-width: 420px;"), ): ui.label("Cancel this operation?").style("font-weight: 600;") ui.label("Discard the partially-created files, or keep them in place as an orphan?").style( "color: var(--color-muted);" ) with ui.row().classes("justify-end w-full").style("gap: 0.5rem;"): ui.button("Back", on_click=lambda _e: dialog.close()).props("flat") ui.button("Keep files", on_click=lambda _e: _choose(False)).props( 'flat data-testid="cancel-keep"' ) ui.button("Discard files", on_click=lambda _e: _choose(True)).props( 'flat color=negative data-testid="cancel-discard"' ) dialog.open() def _open_log_dialog(deps: Any, run_path: Path, ui: Any) -> None: """Open a NiceGUI dialog showing the run's sync-queue job state. The operator-free per-file NAS sync redesign (2026-05-21) removed ``ingest.json``; the per-run "log" is now the run's sync-queue job state. Phase 5/6 source this from the ``sync_state.json`` rollup. """ async def _do_open() -> None: nas_sync = getattr(deps, "nas_sync", None) if deps is not None else None getter = getattr(nas_sync, "get_by_run_path", None) if nas_sync is not None else None row = None if getter is not None: try: row = await getter(run_path) except Exception as exc: # pragma: no cover -- defensive _log.warning("sync-queue lookup failed for %s: %s", run_path, exc) state = getattr(getattr(row, "state", None), "value", None) or "none" try: dialog = ui.dialog() with ( dialog, ui.card() .props('data-testid="run-log-dialog"') .style("min-width: 480px; max-width: 720px;"), ): ui.label(f"Log: {run_path.name}").style("font-weight: 600;") ui.label(f"Sync state: {state}").style("color: var(--color-muted);") if row is None: ui.label("No sync job recorded for this run yet.").style( "font-family: var(--font-mono); font-size: 0.85em;" ) else: for field in ("enqueued_at", "verified_at", "attempts", "last_error"): value = getattr(row, field, None) if value: ui.label(f"{field}: {value}").style( "font-family: var(--font-mono); font-size: 0.85em;" ) ui.button("Close", on_click=dialog.close).props("flat") dialog.open() except Exception as exc: _log.warning("log dialog render failed: %s", exc) _show_toast(ui, "Log dialog unavailable", positive=False) _spawn_background(_do_open()) def _missing_setup_sections(deps: Any) -> tuple[str, ...]: """Return the settings sections the operator still needs to fill in. Mirrors a subset of the §4.9 setup-state evaluation: any setup-state other than READY surfaces at least one section. The Settings page uses this to auto-select the first incomplete section. """ from exlab_wizard.api._dependencies import lims_password_present if deps is None: return ("paths", "lims") config = getattr(deps, "config", None) if config is None: # ``operators`` is intentionally omitted: the chip editor is # deferred, and surfacing it here forced the operator into a # placeholder section before they could reach the main GUI. return ("paths", "lims") missing: list[str] = [] # The single ``paths.app_root`` always carries a sensible Documents-based # default, so there is no "unset paths" section to auto-select for a # config that exists. The rare unwritable-app-root case is surfaced by the # setup banner (``compute_setup_state`` -> ``INCOMPLETE_PATHS_UNWRITABLE``), # not by this Settings section picker. # rclone.conf NAS-sync migration: surface the NAS-remote section when # nas-mode equipment exist but the configured ``nas.remote`` is absent # from rclone.conf, so the setup-incomplete banner auto-selects it. # This mirrors the §4.9 setup gate (``INCOMPLETE_NO_NAS_REMOTE``). if _nas_remote_missing(deps, config): missing.append("nas_remote") if not config.lims.endpoint or not config.lims.email: missing.append("lims") if not lims_password_present(deps) and "lims" not in missing: missing.append("lims") return tuple(missing) def _templates_dir(deps: Any) -> Path | None: """Return the configured templates directory, or ``None``.""" config = getattr(deps, "config", None) if deps is not None else None if config is None or not config.paths.templates_dir: return None return Path(config.paths.templates_dir) def _selected_template_path(deps: Any, state: Any) -> Path | None: """Return the absolute path of the template the wizard state selected. Prefers the resolved ``state.selected_template_path`` the template step stored -- this honours a per-instance (per-project / per-equipment) override the resolver picked, so the pipeline renders the exact file the operator saw (Backend Spec §5.0; design §4.3). Falls back to ``templates_dir / selected_template`` when the state carries no resolved path (e.g. the resolver was not wired), preserving the prior behaviour. Returns ``None`` when no template is selected and no fallback is derivable. """ resolved = getattr(state, "selected_template_path", None) if resolved is not None: return Path(resolved) name = getattr(state, "selected_template", None) if not name: return None templates_dir = _templates_dir(deps) return templates_dir / name if templates_dir is not None else None def _template_locations(deps: Any) -> list[tuple[str, str]]: """Return the manager's scope/location options as ``[(label, value)]``. Always offers ``("Global", "global")``; appends one ``("Equipment <id>", "equipment:<id>")`` per configured equipment so the operator can browse / edit per-equipment template stores. The global option is first so it stays the manager's default scope. """ locations: list[tuple[str, str]] = [("Global", "global")] for equipment_id in _equipment_ids(deps): locations.append((f"Equipment {equipment_id}", f"equipment:{equipment_id}")) return locations def _location_scan_dirs(deps: Any, loc: str) -> list[Path]: """Return the template directories the manager lists for ``loc``. ``"global"`` (or anything unrecognised) lists the flat ``paths.templates_dir``. ``"equipment:<id>"`` lists that equipment's type-segregated per-instance stores (``project/`` + ``run/`` under ``<local_root>/<id>/.exlab-wizard/templates/``) so editing reaches the per-equipment templates the resolver layers in. Missing directories are tolerated -- :func:`list_templates` returns nothing for them. """ from exlab_wizard.constants import TemplateType from exlab_wizard.template.resolution import instance_template_dir if loc.startswith("equipment:"): equipment_id = loc.split(":", 1)[1] config = getattr(deps, "config", None) if deps is not None else None local_root = config.paths.local_root if config is not None else "" if equipment_id and local_root: equipment_dir = Path(local_root) / equipment_id return [ instance_template_dir(equipment_dir, TemplateType.PROJECT.value), instance_template_dir(equipment_dir, TemplateType.RUN.value), ] return [] templates_dir = _templates_dir(deps) return [templates_dir] if templates_dir is not None else [] def _template_names( deps: Any, template_type: str, *, equipment_id: str | None = None, project_path: Path | None = None, ) -> list[str]: """List the resolved template names a wizard should offer for ``template_type``. Resolves through :func:`exlab_wizard.template.resolution.resolve_template_chain` so per-instance (per-equipment / per-project) templates layer over the global ``templates_dir`` when an equipment / project context is known. The wizard page handlers call this at render time before the operator has picked equipment / project, so ``equipment_id`` / ``project_path`` are typically ``None`` -- the resolver then returns the global templates (identical to the pre-resolver behaviour), degrading gracefully. """ config = getattr(deps, "config", None) if deps is not None else None if config is None: return [] try: from exlab_wizard.template.resolution import resolve_template_chain return [ summary.name for summary in resolve_template_chain( config, template_type=template_type, equipment_id=equipment_id, project_path=project_path, ) ] except Exception as exc: _log.warning("template scan failed: %s", exc) return [] def _template_questions_map( deps: Any, template_type: str, *, equipment_id: str | None = None, project_path: Path | None = None, ) -> dict[str, Any]: """Map each resolved template name to its parsed copier questions. Thin wrapper over :func:`_resolve_template_choices` that returns only the questions map (kept as a stable name for existing callers / tests). """ return _resolve_template_choices( deps, template_type, equipment_id=equipment_id, project_path=project_path, ).questions def _resolve_template_choices( deps: Any, template_type: str, *, equipment_id: str | None = None, project_path: Path | None = None, run_scope: str | None = None, ) -> TemplateChoices: """Resolve the templates a wizard should offer for one context. Resolves the chain through :func:`exlab_wizard.template.resolution.resolve_template_chain` (so per-instance templates layer over the global store when an equipment / project context is known), then resolves each template through the real ``TemplateEngine`` to extract its ``copier.yml`` questions. Returns a :class:`TemplateChoices` bundling the offered names (nearest scope first), the per-template questions (driving the dynamic Variables step), and the per-template **absolute resolved path** (so the wizard's submit renders the exact file listed, honouring a per-instance override rather than re-deriving ``templates_dir / name``). A template that fails to resolve is skipped with a WARN. """ from exlab_wizard.template.resolution import TemplateChoices config = getattr(deps, "config", None) if deps is not None else None if config is None: return TemplateChoices() try: from exlab_wizard.constants import TemplateType from exlab_wizard.template.copier_driver import TemplateEngine from exlab_wizard.template.resolution import resolve_template_chain from exlab_wizard.ui.pages import templates as templates_page engine = TemplateEngine() scope = TemplateType(template_type) names: list[str] = [] questions: dict[str, Any] = {} paths: dict[str, Path] = {} for summary in resolve_template_chain( config, template_type=template_type, equipment_id=equipment_id, project_path=project_path, run_scope=run_scope, ): try: resolved = engine.resolve(summary.path, scope) except Exception as exc: _log.warning("template %s failed to resolve: %s", summary.name, exc) continue names.append(summary.name) questions[summary.name] = templates_page.template_questions(resolved.raw_manifest) paths[summary.name] = summary.path return TemplateChoices(names=names, questions=questions, paths=paths) except Exception as exc: _log.warning("template question scan failed: %s", exc) return TemplateChoices() def _lims_catalogue_projects(deps: Any) -> list[dict[str, Any]]: """Read the offline-catalogue projects (disconnected-workstation source). Reads the offline catalogue (``config.lims.offline_catalogue_path``). Returns ``[]`` on any failure -- missing path, schema mismatch, parse error -- so callers can fall through to the next picker source. """ config = getattr(deps, "config", None) if deps is not None else None if config is None: return [] catalogue_path = getattr(config.lims, "offline_catalogue_path", "") or "" if not catalogue_path or not Path(catalogue_path).exists(): return [] try: from exlab_wizard.lims.catalogue import read_catalogue catalogue = read_catalogue(Path(catalogue_path), expected_endpoint=config.lims.endpoint) if catalogue is None: # schema_version mismatch -> treated as absent (§7.2.9.3). return [] return [ { "short_id": project.short_id, "name": project.name, "uid": project.uid, "source": "offline_catalogue", } for project in catalogue.projects ] except Exception as exc: _log.warning("offline catalogue read failed: %s", exc) return [] async def _lims_projects(deps: Any) -> list[dict[str, Any]]: """Return the LIMS projects backing the project wizard's picker. Tries the live LIMS first (``deps.lims_client.list_projects``); on any failure -- client absent, unreachable, auth, timeout -- falls back to the offline catalogue (``config.lims.offline_catalogue_path``). Returns ``[]`` when neither source yields rows, in which case the wizard offers a deliberate manual-entry gate instead of a dropdown. """ lims_client = getattr(deps, "lims_client", None) if deps is not None else None lims_reachable = getattr(deps, "lims_reachable", True) if deps is not None else False if lims_client is not None and lims_reachable: try: projects = await asyncio.wait_for(lims_client.list_projects(), timeout=5.0) rows = [ { "short_id": project.short_id, "name": project.name, "uid": project.uid, "source": "lims", } for project in projects ] if rows: return rows except Exception as exc: _log.warning("live LIMS project list failed: %s", exc) return _lims_catalogue_projects(deps) def _equipment_ids(deps: Any) -> list[str]: """Return the configured equipment IDs.""" config = getattr(deps, "config", None) if deps is not None else None if config is None: return [] return [entry.id for entry in config.equipment] async def _await_session(controller: Any, handle: Any) -> Any: """Await a controller session's pipeline task and return the final handle. ``create_project`` / ``create_run`` return immediately with the post-validation handle and run the rest of the pipeline as a background task tracked in ``controller._tasks`` (the integration suite drains it the same way). We await that task so the wizard shows a real DONE / FAILED outcome rather than the transient RENDERING state. """ task = controller._tasks.get(handle.session_id) if task is not None: with contextlib.suppress(Exception): await task return await controller.status(handle.session_id) async def _consume_session_progress( controller: Any, session_id: str, wizard_state: Any, ui: Any ) -> None: """Fold the controller's WS frames into the wizard's live phase bar (T2) and surface a plugin ``INPUT_REQUIRED`` escalation dialog (T5). Runs inside the wizard's submit coroutine (already bound to the page's client context), so re-rendering the ``@ui.refreshable`` progress view and opening dialogs are safe. Subscribing right after ``create_*`` returns is race-free: ``_launch`` creates the session's event queue before the pipeline starts, so buffered early phases replay in order. On an ``input_required`` frame the §9.1 dialog opens; the loop keeps awaiting frames (the pipeline only resumes once the operator submits). A terminal ``done`` / ``failed`` frame force-closes any open dialog (e.g. the plugin timed out while suspended) and ends the loop. """ from exlab_wizard.ui.components import session_progress progress = getattr(wizard_state, "progress", None) refresh = getattr(wizard_state, "progress_refresh", None) if progress is None: return open_dialog: Any = None try: async for frame in controller.subscribe(session_id): kind = frame.get("kind") if session_progress.apply_frame(progress, frame) and refresh is not None: with contextlib.suppress(Exception): refresh() if kind == "input_required": open_dialog = _open_input_required_dialog( controller, session_id, ui, plugin=frame.get("plugin", ""), reason=frame.get("reason", ""), fields=frame.get("fields") or [], ) if kind in ("done", "failed"): _close_dialog(open_dialog) break except Exception: _log.exception("progress consumer failed for session %s", session_id) def _close_dialog(dialog: Any) -> None: """Best-effort close of a NiceGUI dialog (no-op when ``None`` / test mode).""" closer = getattr(dialog, "close", None) if callable(closer): with contextlib.suppress(Exception): closer() async def _submit_project(deps: Any, state: Any, ui: Any) -> None: """Build a ProjectCreateRequest from the wizard state and run it.""" controller = getattr(deps, "controller", None) if deps is not None else None if controller is None: _show_toast(ui, "Project creation unavailable: controller not initialized", positive=False) return if not state.selected_template: _show_toast(ui, "Pick a template before creating the project", positive=False) return template_path = _selected_template_path(deps, state) if template_path is None: _show_toast(ui, "Pick a template before creating the project", positive=False) return from exlab_wizard.controller.creation import ProjectCreateRequest readme = state.readme_fields request = ProjectCreateRequest( equipment_id=state.selected_equipment or "", template_path=template_path, lims_project={ "uid": str(uuid.uuid4()), "short_id": state.selected_lims_short_id or "", "name_at_creation": state.lims_project_name or "", "source": getattr(state, "selected_lims_source", "manual") or "manual", }, variables=dict(state.template_variables), label=readme.get("label", ""), operator=readme.get("operator", ""), objective=readme.get("objective", ""), ) await _run_creation( controller, controller.create_project, request, ui, label="Project", wizard_state=state ) async def _submit_run(deps: Any, state: Any, run_kind: RunKind, ui: Any) -> None: """Build a RunCreateRequest from the wizard state and run it.""" controller = getattr(deps, "controller", None) if deps is not None else None if controller is None: _show_toast(ui, "Run creation unavailable: controller not initialized", positive=False) return if not state.selected_template: _show_toast(ui, "Pick a template before creating the run", positive=False) return template_path = _selected_template_path(deps, state) if template_path is None: _show_toast(ui, "Pick a template before creating the run", positive=False) return from exlab_wizard.controller.creation import RunCreateRequest readme = state.readme_fields # The run lives under <equipment>/<project name>/ (Backend Spec §3.2); # the controller inherits the parent project's full LIMS identity # (uid / short_id / source) from that project's creation.json. request = RunCreateRequest( equipment_id=state.selected_equipment or "", project_name=state.selected_project_name or "", template_path=template_path, run_kind=run_kind, variables=dict(state.template_variables), label=readme.get("label", ""), operator=readme.get("operator", ""), objective=readme.get("objective", ""), ) kind_label = "Test run" if run_kind is RunKind.TEST else "Run" await _run_creation( controller, controller.create_run, request, ui, label=kind_label, wizard_state=state ) async def _run_creation( controller: Any, create_fn: Callable[[Any], Any], request: Any, ui: Any, *, label: str, wizard_state: Any = None, ) -> None: """Drive a create_* call to completion and toast the outcome. When ``wizard_state`` is supplied, the controller's phase stream is consumed live so the Confirm & Create step's progress bar advances as the pipeline runs (T2); otherwise the call just awaits the final state. """ from exlab_wizard.controller import SessionState try: handle = await create_fn(request) if wizard_state is not None: await _consume_session_progress(controller, handle.session_id, wizard_state, ui) final = await _await_session(controller, handle) except Exception as exc: _log.exception("%s creation raised", label) _show_toast(ui, f"{label} creation failed: {exc}", positive=False) return if final.state is SessionState.DONE: _show_toast(ui, f"{label} created", positive=True) ui.navigate.to("/main") return detail = "" session = controller.session_store.get(handle.session_id) if session is not None and session.error: detail = f": {session.error.get('message', session.error.get('code', ''))}" _show_toast(ui, f"{label} creation {final.state.value}{detail}", positive=False) def _render_run_wizard(deps: Any, run_kind: RunKind, ui: Any) -> Any: from exlab_wizard.constants import RunScope from exlab_wizard.template.resolution import project_dir from exlab_wizard.ui.pages import wizard_run as wizard_run_page state = wizard_run_page.RunWizardState(run_kind=run_kind) # Run templates are narrowed to the run kind's scope (a "test" run only # sees test/both templates; experimental sees experimental/both). scope = RunScope.TEST.value if run_kind is RunKind.TEST else RunScope.EXPERIMENTAL.value config = getattr(deps, "config", None) if deps is not None else None initial = _resolve_template_choices(deps, "run", run_scope=scope) def _resolve_run_templates(equipment_id: str | None, project_name: str | None) -> Any: # Per-project then per-equipment run templates layer over the global # store once the operator picks project + equipment (step 1, before # the template step). Backend Spec §5.0 / §3.2. proj_path = project_dir(config, equipment_id, project_name) if config is not None else None return _resolve_template_choices( deps, "run", equipment_id=equipment_id, project_path=proj_path, run_scope=scope ) return wizard_run_page.render_run_wizard( state=state, templates=initial.names, equipment_ids=_equipment_ids(deps), template_questions=initial.questions, template_paths=initial.paths, on_resolve=_resolve_run_templates, on_submit=lambda submitted: _submit_run(deps, submitted, run_kind, ui), on_cancel=lambda: ui.navigate.to("/main"), ) def _safe_audit(deps: Any) -> list[Any]: """Run the validator audit, swallowing failures to a WARN log.""" validator = getattr(deps, "validator", None) if deps is not None else None if validator is None: return [] try: return list(validator.audit({"kind": AuditScopeKind.ALL})) except Exception as exc: _log.warning("validator.audit failed: %s", exc) return [] def _show_toast(ui: Any, message: str, *, positive: bool) -> None: del ui # toasts route through the notifications helper, not raw ui try: from exlab_wizard.ui import notifications if positive: notifications.notify_success(message) else: notifications.notify_error(message) except Exception as exc: _log.debug("toast notify failed: %s", exc)