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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
97 changes: 7 additions & 90 deletions src/exlab_wizard/ui/mount.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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).
Expand All @@ -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,
Expand Down Expand Up @@ -454,19 +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
Expand Down Expand Up @@ -1310,7 +1293,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:
Expand Down Expand Up @@ -1349,45 +1332,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(
Expand Down Expand Up @@ -2107,25 +2052,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:
Expand All @@ -2137,12 +2063,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)
15 changes: 4 additions & 11 deletions src/exlab_wizard/ui/pages/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 8 additions & 13 deletions src/exlab_wizard/ui/pages/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -54,7 +53,7 @@
"operators": "Operators",
"validator": "Validator",
"logging": "Logging",
"orchestrator": "Orchestrator Mode",
"orchestrator": "Workstation",
"application": "Application",
}

Expand Down Expand Up @@ -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
Expand Down
39 changes: 22 additions & 17 deletions src/exlab_wizard/ui/pages/wizard_equipment.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand All @@ -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",
}

Expand All @@ -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

Expand Down Expand Up @@ -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 "
Expand All @@ -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,
}
7 changes: 2 additions & 5 deletions tests/e2e/_test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions tests/e2e/test_flow_05_browse_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading