From a407e57f800679b89592a46ab724bb3af1438313 Mon Sep 17 00:00:00 2001 From: Alexander Nguyen Date: Fri, 29 May 2026 22:32:26 -0700 Subject: [PATCH 1/2] feat(ui): hide orchestrator/staging from the operator GUI Hide (not remove) the per-equipment `stage` sync mode and the staging-PC relay. Every equipment now syncs directly to the NAS (sync_mode="nas"); the staging backend stays dormant and tested for reversibility. No config schema change, no feature flag. UI surfaces gated: - Add-Equipment wizard: drop the sync-mode step (identity -> paths -> review); dormant _render_sync_mode_step kept as the reversibility hook. - Settings: rename "Orchestrator Mode" -> "Workstation" (section id kept, so the setup gate / routing are untouched); remove the staging-root input. - mount.py: remove the /staging route + footer clear-verified wiring and the helpers it orphaned (_build_staging_state, _bulk_clear_verified, _render_unavailable); keep the per-run tree context-menu actions (they serve nas runs) and reword their toasts off "staging". - main.py: remove the footer "Staging" segment + "Clear verified runs". Backend left intact and dormant: orchestrator/ (incl. the quiescence poller, the auto-sync engine for BOTH nas and stage runs), api/routers/staging.py, SyncMode.STAGE, orchestrator.staging_* fields. Tests: skip the staging-driven UI flows with reversible markers citing the spec; update wizard/settings/footer/persist tests; drop mount glue-fn tests for the removed helpers. Backend staging tests stay green, proving dormancy. Add a root CLAUDE.md documenting the hidden-but-present state so future work does not "fix" the missing UI or delete the shared poller. Spec: docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 24 ++ src/exlab_wizard/ui/mount.py | 98 +------- src/exlab_wizard/ui/pages/main.py | 15 +- src/exlab_wizard/ui/pages/settings.py | 21 +- src/exlab_wizard/ui/pages/wizard_equipment.py | 39 ++-- tests/e2e/_test_app.py | 7 +- tests/e2e/test_flow_05_browse_view.py | 6 +- tests/e2e/test_flow_08_settings.py | 13 ++ tests/e2e/test_flow_09_orchestrator.py | 11 + tests/e2e/test_flow_16_add_equipment.py | 12 +- tests/e2e/test_flow_18_relay_receive.py | 10 + tests/e2e/test_flow_20_file_explorer.py | 7 +- tests/e2e/test_flow_21_stage_ceiling.py | 11 + tests/e2e/test_flow_23_footer_staging.py | 10 + .../test_flow_25_production_main_wiring.py | 21 +- .../test_flow_26_equipment_wizard_persist.py | 15 +- tests/unit/ui/test_mount.py | 209 +----------------- tests/unit/ui/test_settings_page.py | 13 ++ tests/unit/ui/test_staging_page.py | 11 + tests/unit/ui/test_wizard_equipment.py | 23 +- 20 files changed, 213 insertions(+), 363 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b248c00 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# ExLabWizard — project notes for Claude + +## Orchestrator / staging is intentionally hidden (not removed) + +The per-equipment `stage` sync mode and the staging-PC relay +(orchestrator) are **hidden at the UI layer only**. The operator sees a +single model: one instance hosts multiple equipment folders, each syncing +**directly to the NAS** (`sync_mode = "nas"`). + +- The staging **backend is still present and still tested** — `orchestrator/`, + `api/routers/staging.py`, `ui/pages/staging.py`, the `nas_client` stage + branch, `SyncMode.STAGE`, and the `orchestrator.staging_*` config fields all + remain. They are dormant (no equipment is `stage` mode; `staging_root` is + never surfaced). +- **Do not** "fix" the missing staging dock, the absent sync-mode wizard step, + or the missing staging-root setting — their removal from the GUI is + deliberate (see `docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md`). +- **Do not** assume the feature is gone: `orchestrator/quiescence_poller.py` is + the auto-sync engine for **both** nas- and stage-mode runs — never delete it. +- `orchestrator.label` is still **required** (workstation identity in every + `creation.json`); it is unrelated to staging. The Settings section that + collects it is titled "Workstation" (its section id stays `"orchestrator"`). +- To re-enable staging, restore the four UI surfaces listed in the spec's §3.6. + A full backend removal, if ever wanted, is a separate spec. diff --git a/src/exlab_wizard/ui/mount.py b/src/exlab_wizard/ui/mount.py index 1cf6b38..85637ee 100644 --- a/src/exlab_wizard/ui/mount.py +++ b/src/exlab_wizard/ui/mount.py @@ -30,12 +30,15 @@ KEYRING_USERNAME_LIMS, AuditScopeKind, RunKind, - RunSyncState, 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 -from exlab_wizard.orchestrator.staging_query import list_staged_runs if TYPE_CHECKING: from fastapi import FastAPI @@ -103,9 +106,6 @@ def _register_pages(app: FastAPI, ui: Any) -> None: from exlab_wizard.ui.pages import ( settings as settings_page, ) - from exlab_wizard.ui.pages import ( - staging as staging_page, - ) from exlab_wizard.ui.pages import ( templates as templates_page, ) @@ -225,9 +225,6 @@ def _on_search(query: str) -> None: def _on_run_staging_action(path: str, action: str) -> None: _run_staging_action(deps, path, action, ui) - def _on_clear_verified() -> None: - _bulk_clear_verified(deps, 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). @@ -249,7 +246,6 @@ def _on_file_context_action(entry: Any, action: str) -> None: on_navigate_breadcrumb=_on_select_node, on_toggle_right_pane=_on_toggle_right_pane, on_run_staging_action=_on_run_staging_action, - on_clear_verified=_on_clear_verified, on_tree_context_action=_on_tree_context_action, on_file_context_action=_on_file_context_action, on_select_file=_on_select_file, @@ -454,20 +450,6 @@ def _problems() -> Any: last_audit_at=getattr(deps, "last_audit_at", None), ) - @ui.page("/staging") - def _staging() -> Any: - deps = _deps() - state = _build_staging_state(deps) - if state is None: - _render_unavailable( - ui, - "Staging unavailable", - "No config is wired on this app instance.", - ) - return None - return staging_page.render_staging_dock(state) - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -1310,7 +1292,7 @@ def _run_staging_action(deps: Any, path: str, action: str, ui: Any) -> None: config = getattr(deps, "config", None) if deps is not None else None if config is None: - _show_toast(ui, "Staging action unavailable: no config", positive=False) + _show_toast(ui, "Run action unavailable: no config", positive=False) return run_path = Path(path) if action == RUN_CONTEXT_FORCE_SYNC: @@ -1349,45 +1331,7 @@ async def _do_clear() -> None: if action == RUN_CONTEXT_VIEW_LOG: _open_log_dialog(deps, run_path, ui) return - _show_toast(ui, f"Unknown staging action: {action}", positive=False) - - -def _bulk_clear_verified(deps: Any, ui: Any) -> None: - """Bulk-clear every staged run whose sync job is verified. - - Wired from the file-explorer footer's *Clear verified runs* button. - Same in-process dispatch pattern as the per-run actions. The - operator-free per-file NAS sync redesign (2026-05-21) keys the - "clearable" set off the sync-queue job state; Phase 5 swaps this to - the ``sync_state.json`` ``SYNCED`` rollup. - """ - config = getattr(deps, "config", None) if deps is not None else None - if config is None: - _show_toast(ui, "Clear-verified unavailable: no config", positive=False) - return - - async def _do_bulk() -> None: - try: - cleared: list[str] = [] - sync_state_writer = getattr(deps, "sync_state_writer", None) - for summary in list_staged_runs(config=config, sync_state_writer=sync_state_writer): - # 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 - files, _bytes = await asyncio.to_thread(clear_run_dir, Path(summary.path)) - if files > 0: - cleared.append(summary.path) - except Exception as exc: - _log.exception("bulk clear-verified failed") - _show_toast(ui, f"Clear-verified failed: {exc}", positive=False) - return - if cleared: - _show_toast(ui, f"Cleared {len(cleared)} verified run(s)", positive=True) - else: - _show_toast(ui, "No verified runs to clear", positive=True) - - _spawn_background(_do_bulk()) + _show_toast(ui, f"Unknown run action: {action}", positive=False) def _file_context_action( @@ -2107,25 +2051,6 @@ def _safe_audit(deps: Any) -> list[Any]: return [] -def _build_staging_state(deps: Any) -> Any: - from exlab_wizard.ui.pages import staging as staging_page - - config = getattr(deps, "config", None) if deps is not None else None - if config is None: - return None - # Redesign §3.1: orchestrator pipeline is always active; missing - # staging_root surfaces as an empty staging dock, not a None panel. - try: - rows = list_staged_runs( - config=config, - sync_state_writer=getattr(deps, "sync_state_writer", None), - ) - except Exception as exc: - _log.warning("staging_query failed: %s", exc) - return staging_page.StagingDockState(rows=[]) - return staging_page.StagingDockState(rows=list(rows)) - - def _show_toast(ui: Any, message: str, *, positive: bool) -> None: del ui # toasts route through the notifications helper, not raw ui try: @@ -2137,12 +2062,3 @@ def _show_toast(ui: Any, message: str, *, positive: bool) -> None: notifications.notify_error(message) except Exception as exc: _log.debug("toast notify failed: %s", exc) - - -def _render_unavailable(ui: Any, headline: str, subline: str) -> None: - try: - with ui.card().style("max-width: 480px; padding: var(--sp-6);"): - ui.label(headline).style("font-weight: 600;") - ui.label(subline).style("color: var(--color-muted);") - except Exception as exc: - _log.warning("render_unavailable failed: %s", exc) diff --git a/src/exlab_wizard/ui/pages/main.py b/src/exlab_wizard/ui/pages/main.py index 839633e..d04341b 100644 --- a/src/exlab_wizard/ui/pages/main.py +++ b/src/exlab_wizard/ui/pages/main.py @@ -183,7 +183,6 @@ def render_file_explorer_page( on_navigate_breadcrumb: Callable[[str], None] | None = None, on_toggle_right_pane: Callable[[], None] | None = None, on_run_staging_action: Callable[[str, str], None] | None = None, - on_clear_verified: Callable[[], None] | None = None, on_tree_context_action: Callable[[str, str], None] | None = None, on_file_context_action: Callable[[Any, str], None] | None = None, on_select_file: Callable[[Any], None] | None = None, @@ -544,16 +543,10 @@ def _files_header_extra() -> None: label="LIMS", state=s.lims_state, ) - # Footer Staging segment with bulk-clear-verified popover - # (§4.6: the bottom dock's bulk action relocates here). - status_bar_segment.status_bar_segment( - label="Staging", - state=s.staging_state, - ).props('data-testid="footer-staging-segment"') - if on_clear_verified is not None: - ui.button("Clear verified runs", on_click=lambda _evt: on_clear_verified()).props( - 'flat data-testid="footer-clear-verified"' - ) + # Footer "Staging" segment + bulk clear-verified are intentionally + # omitted: orchestrator/staging is hidden at the UI layer (see + # docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md). + # ``MainPageState.staging_state`` stays as an inert field. def _render_centre_file_list( diff --git a/src/exlab_wizard/ui/pages/settings.py b/src/exlab_wizard/ui/pages/settings.py index 1e1b3a7..aef09b0 100644 --- a/src/exlab_wizard/ui/pages/settings.py +++ b/src/exlab_wizard/ui/pages/settings.py @@ -14,7 +14,6 @@ from exlab_wizard.config.models import Config from exlab_wizard.logging import get_logger -from exlab_wizard.paths import suggested_staging_root from exlab_wizard.ui import notifications from exlab_wizard.ui.components import credential_field, test_connection_panel @@ -54,7 +53,7 @@ "operators": "Operators", "validator": "Validator", "logging": "Logging", - "orchestrator": "Orchestrator Mode", + "orchestrator": "Workstation", "application": "Application", } @@ -609,20 +608,16 @@ def _reset_extensions() -> None: label="Rotated log copies kept", value=draft.logging.central_log_keep ).bind_value(draft.logging, "central_log_keep") elif section == "orchestrator": - # ``label`` is required (it identifies this workstation in every - # run's creation.json). ``staging_root`` is opt-in: blank means - # this device is not a staging PC. The placeholder shows an - # OS-appropriate suggestion without prefilling the value -- the - # directory is created only when a non-empty path is saved (see - # ui.mount._persist_config). + # ``label`` is required: it identifies this workstation in every + # run's creation.json. The staging-root input is intentionally + # hidden (orchestrator/staging hidden — see + # docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md); + # ``orchestrator.staging_root`` stays blank, so this device never + # acts as a staging PC. The section id stays "orchestrator" so the + # setup gate / settings_sections_for keep working. ui.input(label="Workstation label", value=draft.orchestrator.label).bind_value( draft.orchestrator, "label" ) - ui.input( - label="Staging root (optional)", - value=draft.orchestrator.staging_root, - placeholder=str(suggested_staging_root()), - ).bind_value(draft.orchestrator, "staging_root") elif section == "application": # "Start at login" (T8): applied immediately (NOT draft-bound, # §7.13). Seeded from the real registration state; on toggle it diff --git a/src/exlab_wizard/ui/pages/wizard_equipment.py b/src/exlab_wizard/ui/pages/wizard_equipment.py index 2ebdccd..c390e45 100644 --- a/src/exlab_wizard/ui/pages/wizard_equipment.py +++ b/src/exlab_wizard/ui/pages/wizard_equipment.py @@ -1,19 +1,21 @@ """Add-Equipment wizard (GUI/Orchestrator Redesign §6). -Four-step wizard launched from the main-window toolbar: +Three-step wizard launched from the main-window toolbar: 1. Identity — equipment ID (validated against ``^[A-Z][A-Z0-9_]*$``) + label. 2. Paths — local_root (where this device acquires runs). -3. Sync mode — pick ``nas`` (acquire + sync directly to NAS) or - ``stage`` (acquire + push to a connected PC's staging area). Neither - mode collects a per-equipment transport: the NAS connection is the - single ``nas:`` remote and the staging hop is - ``orchestrator.staging_remote`` (both configured in Settings). -4. Review & confirm — assembles a validated EquipmentConfig via the +3. Review & confirm — assembles a validated EquipmentConfig via the shared ``build_equipment_config()`` and posts it through ``POST /config/equipment``. +The sync-mode step is intentionally hidden: orchestrator / staging is +hidden at the UI layer (see +``docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md``), +so every equipment is created in ``nas`` mode (sync directly to NAS via +the single ``nas:`` remote). The dormant ``_render_sync_mode_step`` is +kept so re-listing it restores the step verbatim. + The render function is pure (state + callbacks); the actual NiceGUI mount layer wires the on-confirm callback to the config router. """ @@ -32,17 +34,21 @@ _log = get_logger(__name__) +# The "sync_mode" step is intentionally omitted: orchestrator / staging is +# hidden at the UI layer (see +# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md). +# Every equipment is created in ``nas`` mode. Re-listing "sync_mode" here and +# in ``EQUIPMENT_STEP_TITLES`` / ``_STEP_RENDERERS`` below restores the step +# verbatim (the renderer is kept, dormant). EQUIPMENT_WIZARD_STEPS: tuple[str, ...] = ( "identity", "paths", - "sync_mode", "review", ) EQUIPMENT_STEP_TITLES: dict[str, str] = { "identity": "Identity", "paths": "Paths", - "sync_mode": "Sync mode", "review": "Review & confirm", } @@ -58,13 +64,13 @@ class EquipmentWizardState: # Step 2 local_root: str = "" nas_root: str = "" - # Step 3 -- sync_mode is "nas" or "stage". rclone.conf migration - # (Phase 8): neither mode carries a per-equipment transport. The - # ``nas:`` remote defines the NAS connection and - # ``orchestrator.staging_remote`` defines the staging hop, so picking - # the mode is the only per-equipment choice. + # sync_mode is retained but no longer operator-selectable: the sync-mode + # wizard step is hidden (orchestrator/staging hidden — see module note), + # so every equipment is created in "nas" mode. The field stays so the + # dormant ``_render_sync_mode_step`` and ``SyncMode.STAGE`` backend remain + # one edit away from re-enabling. sync_mode: str = "nas" - # Step 4 + # Review step last_error: str | None = None confirmed: bool = False @@ -323,7 +329,6 @@ def _render_review_step( ui.label(f"Label: {state.label}") ui.label(f"Local root: {state.local_root}") ui.label(f"NAS root: {state.nas_root}") - ui.label(f"Sync mode: {state.sync_mode}") if state.sync_mode == "nas": ui.label( "This device syncs directly to the NAS via the rclone remote " @@ -343,6 +348,6 @@ def _render_review_step( _STEP_RENDERERS: dict[str, _StepRenderer] = { "identity": _render_identity_step, "paths": _render_paths_step, - "sync_mode": _render_sync_mode_step, + # "sync_mode": _render_sync_mode_step, # hidden — see EQUIPMENT_WIZARD_STEPS note "review": _render_review_step, } diff --git a/tests/e2e/_test_app.py b/tests/e2e/_test_app.py index 08aa0aa..c0ec684 100644 --- a/tests/e2e/_test_app.py +++ b/tests/e2e/_test_app.py @@ -352,7 +352,8 @@ def main_index( }, } - del orchestrator # accepted for callers; the redesign always renders staging surfaces + del orchestrator # accepted for back-compat with existing test URLs; the + # operator UI no longer renders staging surfaces (orchestrator/staging hidden). # Phase 4 (Option B): resolve the ?file= selection against the seeded # feed via the production helper so the demo mirrors real behavior. @@ -424,9 +425,6 @@ def _on_toggle_right_pane() -> None: def _on_run_staging_action(path: str, action: str) -> None: test_state.last_action = f"run.{action}:{path}" - def _on_clear_verified() -> None: - test_state.last_action = "clear_verified" - def _on_tree_context_action(node_id: str, action: str) -> None: test_state.last_action = f"tree.{action}:{node_id}" ui.navigate.to(f"/settings?active=equipment&equipment_id={node_id}") @@ -485,7 +483,6 @@ def _on_search(query: str) -> None: on_navigate_breadcrumb=_on_select_node, on_toggle_right_pane=_on_toggle_right_pane, on_run_staging_action=_on_run_staging_action, - on_clear_verified=_on_clear_verified, on_tree_context_action=_on_tree_context_action, on_file_context_action=_on_file_context_action, on_select_file=_on_select_file, diff --git a/tests/e2e/test_flow_05_browse_view.py b/tests/e2e/test_flow_05_browse_view.py index 425121a..8a5bdb6 100644 --- a/tests/e2e/test_flow_05_browse_view.py +++ b/tests/e2e/test_flow_05_browse_view.py @@ -28,9 +28,9 @@ def test_flow_05_browse_view(page, server_url) -> None: # Right-pane tabs (Metadata replaces the legacy Details tab). main.tab_metadata.wait_for(state="visible") main.tab_problems.wait_for(state="visible") - # Footer (Redesign §4.6): Staging segment + bulk Clear-verified button. - main.footer_staging_segment.wait_for(state="visible") - main.footer_clear_verified.wait_for(state="visible") + # The footer "Staging" segment + bulk Clear-verified are intentionally + # absent — orchestrator/staging hidden (see + # docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md). # Tree contains the seeded equipment label. assert page.locator('[data-testid="main-tree"]').inner_text().find("TEST_EQ1") >= 0 diff --git a/tests/e2e/test_flow_08_settings.py b/tests/e2e/test_flow_08_settings.py index d5329f5..2d980d2 100644 --- a/tests/e2e/test_flow_08_settings.py +++ b/tests/e2e/test_flow_08_settings.py @@ -103,3 +103,16 @@ def test_flow_08_lims_password_cancel_discards_edit(page, server_url) -> None: settings.lims_password_input.fill("typo-password") settings.lims_password_cancel.click() expect(settings.lims_password_status).to_have_text("Status: Not set", timeout=5_000) + + +def test_flow_08_workstation_section_hides_staging_root(page, server_url) -> None: + """The renamed "Workstation" section (id still ``orchestrator``) collects the + label but no staging-root field — orchestrator/staging hidden (see + docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md).""" + settings = SettingsPage(page) + page.goto(f"{server_url}/settings?active=orchestrator") + page.wait_for_load_state("networkidle") + settings.section("orchestrator").wait_for(state="visible", timeout=10_000) + body = settings.section("orchestrator").inner_text() + assert "Workstation label" in body + assert "Staging root" not in body diff --git a/tests/e2e/test_flow_09_orchestrator.py b/tests/e2e/test_flow_09_orchestrator.py index 6a10723..1a52d5c 100644 --- a/tests/e2e/test_flow_09_orchestrator.py +++ b/tests/e2e/test_flow_09_orchestrator.py @@ -14,8 +14,19 @@ from __future__ import annotations +import pytest + from tests.e2e.page_objects.staging_page import StagingPage +# Orchestrator/staging is hidden at the UI layer; the staging dock is no longer +# reachable from the operator UI. The dock + test app /staging route are kept +# for reversibility. See +# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md. +pytestmark = pytest.mark.skip( + reason="orchestrator/staging hidden — see " + "docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md" +) + def test_flow_09_orchestrator(page, server_url) -> None: staging = StagingPage(page) diff --git a/tests/e2e/test_flow_16_add_equipment.py b/tests/e2e/test_flow_16_add_equipment.py index 78c5c70..0c19502 100644 --- a/tests/e2e/test_flow_16_add_equipment.py +++ b/tests/e2e/test_flow_16_add_equipment.py @@ -1,7 +1,10 @@ """E2E flow 16: Add-Equipment wizard (Redesign §6). -Drives the four-step wizard end to end against the test app: -identity → paths → sync_mode → review → confirm. +Drives the wizard end to end against the test app: +identity → paths → review → confirm. The sync-mode step is hidden +(orchestrator/staging hidden — see +docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md), so +every equipment is created in nas mode. The test app mounts the wizard at ``/wizard/equipment?step=`` so each step can be loaded directly; the production navigation between @@ -12,6 +15,7 @@ from __future__ import annotations +import pytest from playwright.sync_api import expect from tests.e2e.page_objects.wizard_equipment_page import WizardEquipmentPage @@ -49,6 +53,10 @@ def test_flow_16_add_equipment_paths_step(page, server_url) -> None: wiz.nas_root.wait_for(state="visible") +@pytest.mark.skip( + reason="sync-mode wizard step hidden (orchestrator/staging hidden) — see " + "docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md" +) def test_flow_16_add_equipment_sync_mode_step(page, server_url) -> None: """Sync mode step renders the nas/stage radio.""" wiz = WizardEquipmentPage(page) diff --git a/tests/e2e/test_flow_18_relay_receive.py b/tests/e2e/test_flow_18_relay_receive.py index 45abfb6..3493e6d 100644 --- a/tests/e2e/test_flow_18_relay_receive.py +++ b/tests/e2e/test_flow_18_relay_receive.py @@ -7,6 +7,16 @@ from __future__ import annotations +import pytest + +# Relay-receive depends on a configured staging_root, which is no longer +# surfaced: orchestrator/staging is hidden at the UI layer. See +# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md. +pytestmark = pytest.mark.skip( + reason="orchestrator/staging hidden — see " + "docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md" +) + def _goto(page, url: str, *, retries: int = 2) -> None: last: Exception | None = None diff --git a/tests/e2e/test_flow_20_file_explorer.py b/tests/e2e/test_flow_20_file_explorer.py index 627ec18..0fb0fa3 100644 --- a/tests/e2e/test_flow_20_file_explorer.py +++ b/tests/e2e/test_flow_20_file_explorer.py @@ -34,9 +34,10 @@ def test_flow_20_file_explorer_renders_three_regions(page, server_url) -> None: page.locator(f'[data-testid="{testid}"]').wait_for(state="visible", timeout=10_000) # Tree has at least one equipment node. page.locator('[data-testid="tree-node-equipment"]').first.wait_for(state="visible") - # Footer Staging segment + Clear verified action. - page.locator('[data-testid="footer-staging-segment"]').wait_for(state="visible") - page.locator('[data-testid="footer-clear-verified"]').wait_for(state="visible") + # Footer "Staging" segment + bulk clear-verified are intentionally absent — + # orchestrator/staging hidden (see + # docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md). + # Per-run sync actions live on the metadata pane (see the next test). def test_flow_20_select_run_node_renders_metadata_pane(page, server_url) -> None: diff --git a/tests/e2e/test_flow_21_stage_ceiling.py b/tests/e2e/test_flow_21_stage_ceiling.py index a391043..306ee91 100644 --- a/tests/e2e/test_flow_21_stage_ceiling.py +++ b/tests/e2e/test_flow_21_stage_ceiling.py @@ -8,6 +8,17 @@ from __future__ import annotations +import pytest + +# Stage-mode equipment can no longer be created (the sync-mode wizard step is +# hidden), so the stage-ceiling note is unreachable from the operator UI: +# orchestrator/staging is hidden. See +# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md. +pytestmark = pytest.mark.skip( + reason="orchestrator/staging hidden — see " + "docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md" +) + def _goto(page, url: str, *, retries: int = 2) -> None: last: Exception | None = None diff --git a/tests/e2e/test_flow_23_footer_staging.py b/tests/e2e/test_flow_23_footer_staging.py index ce1cf67..c1998e1 100644 --- a/tests/e2e/test_flow_23_footer_staging.py +++ b/tests/e2e/test_flow_23_footer_staging.py @@ -6,6 +6,16 @@ from __future__ import annotations +import pytest + +# The footer Staging segment + bulk Clear-verified were removed: orchestrator/ +# staging is hidden at the UI layer. See +# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md. +pytestmark = pytest.mark.skip( + reason="orchestrator/staging hidden — see " + "docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md" +) + def _goto(page, url: str, *, retries: int = 2) -> None: last: Exception | None = None diff --git a/tests/e2e/test_flow_25_production_main_wiring.py b/tests/e2e/test_flow_25_production_main_wiring.py index dc05cef..7dbeb31 100644 --- a/tests/e2e/test_flow_25_production_main_wiring.py +++ b/tests/e2e/test_flow_25_production_main_wiring.py @@ -17,6 +17,8 @@ from __future__ import annotations +import pytest + def _goto(page, url: str, *, retries: int = 2) -> None: """Navigate to ``url`` with one retry on transient NiceGUI failures.""" @@ -41,11 +43,15 @@ def test_flow_25_main_route_renders_redesigned_layout(page, server_url) -> None: """The /main route mounts the redesigned renderer, not the legacy one. Asserts the six toolbar buttons (the legacy toolbar had five and no - Add Equipment), the metadata-tab testid (the legacy renderer - emitted ``tab-details`` instead), and the footer Clear-verified - button (legacy footer had three status segments and no bulk - action). If any of these fail, ``/main`` is back on the legacy - renderer and the bug class has returned. + Add Equipment) and the metadata-tab testid (the legacy renderer + emitted ``tab-details`` instead). If any of these fail, ``/main`` is + back on the legacy renderer and the bug class has returned. + + (The footer Clear-verified button is no longer asserted: orchestrator/ + staging is hidden — see + docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md. + The six-button toolbar + tab-metadata already distinguish the + redesigned renderer from the legacy one.) """ _goto(page, f"{server_url}/main") for testid in ( @@ -61,7 +67,6 @@ def test_flow_25_main_route_renders_redesigned_layout(page, server_url) -> None: # tab-details. A legacy renderer would fail this assertion. page.locator('[data-testid="tab-metadata"]').wait_for(state="visible", timeout=5_000) page.locator('[data-testid="tab-problems"]').wait_for(state="visible", timeout=5_000) - page.locator('[data-testid="footer-clear-verified"]').wait_for(state="visible", timeout=5_000) # The legacy renderer rendered ``tab-details`` — explicitly assert # it's gone so a future revert is caught. assert page.locator('[data-testid="tab-details"]').count() == 0 @@ -169,6 +174,10 @@ def test_flow_25_received_equipment_disables_creation_buttons(page, server_url) assert add_eq.get_attribute("aria-disabled") != "true" +@pytest.mark.skip( + reason="orchestrator/staging hidden — footer Clear-verified removed; see " + "docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md" +) def test_flow_25_footer_clear_verified_routes_to_callback(page, server_url) -> None: """The footer Clear-verified button is wired to a callback. diff --git a/tests/e2e/test_flow_26_equipment_wizard_persist.py b/tests/e2e/test_flow_26_equipment_wizard_persist.py index 688ef60..cafca14 100644 --- a/tests/e2e/test_flow_26_equipment_wizard_persist.py +++ b/tests/e2e/test_flow_26_equipment_wizard_persist.py @@ -67,22 +67,15 @@ def test_equipment_wizard_confirm_persists_to_config(browser, prod_server) -> No wiz.label.fill("Confocal Microscope 1") wiz.next_button.click() - # 2. Paths. + # 2. Paths. The sync-mode step is hidden (orchestrator/staging hidden — + # see docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md), + # so paths advances straight to review; every equipment is nas-mode. wiz.local_root.wait_for(state="visible", timeout=10_000) wiz.local_root.fill("/data/MICROSCOPE_01") wiz.nas_root.fill("/srv/nas/MICROSCOPE_01") wiz.next_button.click() - # 3. Sync mode -- nas is the default. rclone.conf migration (Phase 8) - # removed the per-equipment SFTP/SMB transport fields: nas-mode now - # just shows a note that the connection is the single nas: remote - # (configured in Settings -> NAS Remote). Picking the mode is the - # only choice, so advancing is immediate. - wiz.sync_mode.wait_for(state="visible", timeout=10_000) - wiz.nas_note.wait_for(state="visible", timeout=10_000) - wiz.next_button.click() - - # 4. Review -> Confirm. + # 3. Review -> Confirm. wiz.confirm.wait_for(state="visible", timeout=10_000) wiz.confirm.click() page.wait_for_load_state("networkidle") diff --git a/tests/unit/ui/test_mount.py b/tests/unit/ui/test_mount.py index e0cda4b..438be70 100644 --- a/tests/unit/ui/test_mount.py +++ b/tests/unit/ui/test_mount.py @@ -96,36 +96,6 @@ def __exit__(self, *_args: Any) -> bool: return False -class _FakeUI: - """Records cards / labels and exposes a ``navigate.to`` spy.""" - - def __init__(self) -> None: - self.cards = 0 - self.labels: list[str] = [] - self.navigated: list[str] = [] - self.navigate = SimpleNamespace(to=self.navigated.append) - - def card(self, *_args: Any, **_kwargs: Any) -> _Fluent: - self.cards += 1 - return _Fluent() - - def label(self, text: str = "", *_args: Any, **_kwargs: Any) -> _Fluent: - self.labels.append(text) - return _Fluent() - - -class _BoomUI: - """A ``ui`` whose element factories raise -- exercises render except paths.""" - - def card(self, *_args: Any, **_kwargs: Any) -> Any: - msg = "no ui slot" - raise RuntimeError(msg) - - def label(self, *_args: Any, **_kwargs: Any) -> Any: - msg = "no ui slot" - raise RuntimeError(msg) - - class _FakeController: """Duck-typed CreationController for the create-flow helpers.""" @@ -452,51 +422,11 @@ def test_safe_audit_forwards_validator_output() -> None: assert mount._safe_audit(deps) == expected -# --------------------------------------------------------------------------- -# _build_staging_state -# --------------------------------------------------------------------------- - - -def test_staging_state_when_staging_root_missing(tmp_path: Path) -> None: - """Redesign §3.1: orchestrator pipeline is always on, but a missing - staging_root on disk surfaces as empty rows, not a None panel.""" - deps = _deps( - config=_config( - orchestrator_label="LAB", - orchestrator_staging_root=str(tmp_path / "does-not-exist"), - ), - ) - state = mount._build_staging_state(deps) - # State may be None when staging is empty or not built; either way the - # always-on contract doesn't promise rows when there are none. - assert state is None or state.rows == [] - - -def test_staging_state_none_when_no_config() -> None: - assert mount._build_staging_state(_deps()) is None - - -def test_staging_state_returns_empty_rows_on_query_failure( - monkeypatch: pytest.MonkeyPatch, -) -> None: - deps = _deps( - config=_config( - orchestrator_label="LAB", - orchestrator_staging_root="/staging", - ), - ) - - def _raise(*_args: Any, **_kwargs: Any) -> None: - msg = "no staging root" - raise RuntimeError(msg) - - monkeypatch.setattr( - "exlab_wizard.orchestrator.staging_query.list_staged_runs", - _raise, - ) - state = mount._build_staging_state(deps) - assert state is not None - assert state.rows == [] +# NOTE: _build_staging_state was removed when the /staging route was hidden +# (orchestrator/staging hidden — see +# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md). The +# staging read-side itself (orchestrator.staging_query.list_staged_runs) stays +# and is covered by test_staging_query. # --------------------------------------------------------------------------- @@ -663,22 +593,8 @@ def _boom(_deps: Any, _cfg: Any) -> None: assert any("live config reload failed" in r.message for r in caplog.records) -# --------------------------------------------------------------------------- -# _render_unavailable -# --------------------------------------------------------------------------- - - -def test_render_unavailable_renders_headline_and_subline() -> None: - ui = _FakeUI() - mount._render_unavailable(ui, "Staging unavailable", "Orchestrator disabled") - assert "Staging unavailable" in ui.labels - assert "Orchestrator disabled" in ui.labels - - -def test_render_unavailable_swallows_failure(caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level("WARNING"): - mount._render_unavailable(_BoomUI(), "headline", "subline") - assert any("render_unavailable" in r.message for r in caplog.records) +# NOTE: _render_unavailable was removed with the /staging route (its only +# caller) — orchestrator/staging hidden; see the design spec referenced above. # --------------------------------------------------------------------------- @@ -1698,21 +1614,6 @@ async def enqueue(self, run_path: Path) -> SimpleNamespace: return SimpleNamespace(state="queued", job_id="j-1") -def _drain_background() -> None: - """Run any pending mount background tasks to completion.""" - import asyncio as _aio - - loop = _aio.new_event_loop() - try: - loop.run_until_complete(_aio.sleep(0)) - # Drain the mount's strong-ref set; each task is in the same loop. - pending = [t for t in mount._BACKGROUND_TASKS if not t.done()] - if pending: - loop.run_until_complete(_aio.gather(*pending, return_exceptions=True)) - finally: - loop.close() - - async def test_run_staging_action_force_sync_invokes_nas_sync_enqueue() -> None: """Force-sync routes to ``deps.nas_sync.enqueue`` with the run path.""" nas_sync = _StubNasSync() @@ -1779,62 +1680,10 @@ def _stub(run_path: Path) -> tuple[int, int]: assert captured == [Path("EQ1/proj/Run_x")] -# --------------------------------------------------------------------------- -# _bulk_clear_verified: success / no config / error -# --------------------------------------------------------------------------- - - -async def test_bulk_clear_verified_clears_verified_rows( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The bulk action clears every ``synced`` row and toasts the count.""" - cleared: list[Path] = [] - - def _summary(path: str, state: str) -> SimpleNamespace: - return SimpleNamespace(path=path, current_state=state) - - monkeypatch.setattr( - mount, - "list_staged_runs", - lambda **_kw: [ - _summary("/staging/EQ1/proj/Run_a", "synced"), - _summary("/staging/EQ1/proj/Run_b", "synced"), - _summary("/staging/EQ1/proj/Run_c", "syncing"), - ], - ) - monkeypatch.setattr(mount, "clear_run_dir", lambda p: cleared.append(p) or (1, 10)) - deps = _deps(config=_config()) - ui = _UiSpy() - mount._bulk_clear_verified(deps, ui) - pending = [t for t in mount._BACKGROUND_TASKS if not t.done()] - for task in pending: - await task - # Only the two verified rows were cleared. - assert cleared == [Path("/staging/EQ1/proj/Run_a"), Path("/staging/EQ1/proj/Run_b")] - - -def test_bulk_clear_verified_no_config_toasts_and_returns() -> None: - """The early-exit when config is missing produces a toast, no task.""" - deps = _deps(config=None) - ui = _UiSpy() - mount._bulk_clear_verified(deps, ui) - - -async def test_bulk_clear_verified_logs_helper_exception( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """An exception from the clear sweep is caught + toasted.""" - - def _raise(**_kw: Any) -> list[Any]: - raise RuntimeError("staging walker exploded") - - monkeypatch.setattr(mount, "list_staged_runs", _raise) - deps = _deps(config=_config()) - ui = _UiSpy() - mount._bulk_clear_verified(deps, ui) - pending = [t for t in mount._BACKGROUND_TASKS if not t.done()] - for task in pending: - await task +# NOTE: _bulk_clear_verified was removed with the footer "Clear verified runs" +# button (its only caller) — orchestrator/staging hidden; see the design spec. +# Per-run clear (the kept tree context-menu action) is still covered by +# test_run_staging_action_clear_verified_invokes_clear above. # --------------------------------------------------------------------------- @@ -2931,23 +2780,6 @@ def test_metadata_for_owned_equipment_no_match_returns_empty() -> None: assert mount._metadata_for_owned_equipment("EQ1", config) == {} -async def test_bulk_clear_verified_no_verified_rows_toasts( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """With no SYNCED rows the sweep clears nothing and toasts 'none'.""" - monkeypatch.setattr( - mount, - "list_staged_runs", - lambda **_kw: [SimpleNamespace(path="/staging/EQ1/Run_a", current_state="syncing")], - ) - cleared: list[Any] = [] - monkeypatch.setattr(mount, "clear_run_dir", lambda p: cleared.append(p) or (0, 0)) - mount._bulk_clear_verified(_deps(config=_config()), _UiSpy()) - for task in [t for t in mount._BACKGROUND_TASKS if not t.done()]: - await task - assert cleared == [] # syncing rows are never cleared - - async def test_toggle_keep_local_unresolvable_relative_path_toasts( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -3009,22 +2841,3 @@ def _boom(*_a: Any, **_k: Any) -> None: with caplog.at_level("WARNING"): assert mount._template_questions_map(_deps(config=_config()), "project") == {} assert any("template question scan" in r.message for r in caplog.records) - - -def test_build_staging_state_query_failure_returns_empty_rows( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -) -> None: - """A staging-query failure degrades to an empty dock, not a crash.""" - - def _raise(**_kw: Any) -> Any: - msg = "no staging root" - raise RuntimeError(msg) - - # mount imports ``list_staged_runs`` into its own namespace, so patch there. - monkeypatch.setattr(mount, "list_staged_runs", _raise) - deps = _deps(config=_config(orchestrator_label="LAB", orchestrator_staging_root="/staging")) - with caplog.at_level("WARNING"): - state = mount._build_staging_state(deps) - assert state is not None - assert state.rows == [] - assert any("staging_query failed" in r.message for r in caplog.records) diff --git a/tests/unit/ui/test_settings_page.py b/tests/unit/ui/test_settings_page.py index a04ecbc..15042e6 100644 --- a/tests/unit/ui/test_settings_page.py +++ b/tests/unit/ui/test_settings_page.py @@ -18,6 +18,8 @@ from exlab_wizard.config.models import Config from exlab_wizard.ui.components import credential_field from exlab_wizard.ui.pages.settings import ( + SECTION_TITLES, + SETTINGS_SECTIONS, build_settings_draft, finalize_settings_draft, lims_credential_initial_state, @@ -101,6 +103,17 @@ def test_finalize_allows_blank_staging_root() -> None: assert finalized.orchestrator.label == "BENCH-1" +def test_orchestrator_section_is_titled_workstation() -> None: + """Orchestrator/staging is hidden at the UI layer (see + docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md): the + section id stays ``"orchestrator"`` so the setup gate and section routing + are untouched, but its operator-visible title is "Workstation" and only the + label is collected (the staging-root input is gone). Guards against a revert + to the old "Orchestrator Mode" title or dropping the section entirely.""" + assert SECTION_TITLES["orchestrator"] == "Workstation" + assert "orchestrator" in SETTINGS_SECTIONS + + def test_finalize_raises_on_invalid_edit() -> None: draft = build_settings_draft(None) # logging.level only accepts DEBUG/INFO/WARN/ERROR. diff --git a/tests/unit/ui/test_staging_page.py b/tests/unit/ui/test_staging_page.py index 9ef76ef..88862cf 100644 --- a/tests/unit/ui/test_staging_page.py +++ b/tests/unit/ui/test_staging_page.py @@ -8,6 +8,8 @@ from __future__ import annotations +import pytest + from exlab_wizard.constants import RunSyncState from exlab_wizard.orchestrator.staging_query import StagedRunSummary from exlab_wizard.ui.pages.staging import ( @@ -21,6 +23,15 @@ state_pill_props, ) +# The staging dock is no longer routed from the operator UI; this page module +# and its renderers are kept dormant for reversibility, so these tests are +# skipped rather than deleted. See +# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md. +pytestmark = pytest.mark.skip( + reason="orchestrator/staging hidden — see " + "docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md" +) + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- diff --git a/tests/unit/ui/test_wizard_equipment.py b/tests/unit/ui/test_wizard_equipment.py index ec4cb60..80ccd19 100644 --- a/tests/unit/ui/test_wizard_equipment.py +++ b/tests/unit/ui/test_wizard_equipment.py @@ -14,11 +14,28 @@ ) -def test_wizard_has_four_steps_without_signal_step() -> None: - """The completeness-signal step is removed by the quiescence redesign.""" - assert EQUIPMENT_WIZARD_STEPS == ("identity", "paths", "sync_mode", "review") +def test_wizard_has_three_steps_with_sync_mode_hidden() -> None: + """The sync-mode step is hidden (orchestrator/staging hidden — see + docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md), so the + wizard is identity → paths → review. The completeness-signal step was + already removed by the quiescence redesign.""" + assert EQUIPMENT_WIZARD_STEPS == ("identity", "paths", "review") assert set(EQUIPMENT_STEP_TITLES) == set(EQUIPMENT_WIZARD_STEPS) assert "signal" not in EQUIPMENT_WIZARD_STEPS + assert "sync_mode" not in EQUIPMENT_WIZARD_STEPS + + +def test_assembled_equipment_defaults_to_nas_sync_mode() -> None: + """With the sync-mode step hidden, every wizard-built equipment is nas-mode.""" + from exlab_wizard.constants import SyncMode + + s = EquipmentWizardState() # operator never picks a mode + s.equipment_id = "FLOW_99" + s.label = "Flow Cytometer 99" + s.local_root = "/data/lab" + s.nas_root = "//nas01/lab" + eq = assemble_equipment_config(s) + assert eq.sync_mode == SyncMode.NAS def _state_filled_for(step: str) -> EquipmentWizardState: From 7bd656eb218be52ff0a473c32faf324139b08f1e Mon Sep 17 00:00:00 2001 From: Alexander Nguyen Date: Sat, 30 May 2026 00:06:40 -0700 Subject: [PATCH 2/2] style(ui): ruff-format mount.py after /staging route removal Removing the @ui.page("/staging") block left a single blank line before the "Helpers" divider; ruff format wants two. No behavioural change. Co-Authored-By: Claude Opus 4.8 --- src/exlab_wizard/ui/mount.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/exlab_wizard/ui/mount.py b/src/exlab_wizard/ui/mount.py index 85637ee..80e12d8 100644 --- a/src/exlab_wizard/ui/mount.py +++ b/src/exlab_wizard/ui/mount.py @@ -450,6 +450,7 @@ def _problems() -> Any: last_audit_at=getattr(deps, "last_audit_at", None), ) + # --------------------------------------------------------------------------- # Helpers # ---------------------------------------------------------------------------