diff --git a/docs/REMAINING_WORK_TASKS.md b/docs/REMAINING_WORK_TASKS.md index 101ea14..33c72bc 100644 --- a/docs/REMAINING_WORK_TASKS.md +++ b/docs/REMAINING_WORK_TASKS.md @@ -17,7 +17,7 @@ Status legend: `⬜ Not started` · `🟡 In progress` · `✅ Done` · `⛔ Blo ## Phase 1 — Release-blocking correctness -### - [ ] T1 — Inject the real `ReadmeGenerator` (+ reconcile `ReadmeContext` / return type) +### - [x] T1 — Inject the real `ReadmeGenerator` (+ reconcile `ReadmeContext` / return type) - **Spec:** [§C1](./REMAINING_WORK.md#c1--production-runs-on-the-stub-readme-generator--real-defect) · **Category:** C · **Effort:** M · **Priority:** Highest (release-blocking) - **Why:** Production builds the controller without `readme_generator=`, so it runs on `NoOpReadmeGenerator` — GUI-created runs ship a stub README with no YAML front matter and @@ -29,14 +29,24 @@ Status legend: `⬜ Not started` · `🟡 In progress` · `✅ Done` · `⛔ Blo against a real template. - **Risk watch:** the real generator raises on missing/typed-wrong fields — confirm `_validate_inputs` gates align so previously-passing creations don't start failing. -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** Injected `ReadmeGenerator()` in `tray.dependencies._build_controller` (the sole + production constructor, covering both the lifespan build and `apply_live_config`). Replaced the + controller's flat placeholder `ReadmeContext` with the canonical layered type from + `exlab_wizard.readme`; the controller's `ReadmeGeneratorProtocol` + `NoOpReadmeGenerator` now use + the `tuple[Path, Path]` contract. Added `CreationController._build_readme_context` (partitions + `readme_extra` across template/config/custom layers by id, maps decls, fills the §10.6 system + block: `created_by` = OS user, `project` = LIMS short id (§3.1), `run` = run dir / null). Presence is + already gated by `_validate_inputs` and the GUI submits no typed extra fields yet, so the + generator's strict validation adds no new failures for current creations (it stays the backstop). + New end-to-end test `test_real_readme_generator_writes_frontmatter_and_cache` asserts the + four-layer front matter + `readme_fields.json`. Full unit+integration suite green (2201 passed). --- ## Phase 2 — Session-control epic (build in order; they compound) -### - [ ] T2 — Event-subscription consumer in the creation flow (shared foundation) +### - [x] T2 — Event-subscription consumer in the creation flow (shared foundation) - **Spec:** [§B4](./REMAINING_WORK.md#b4--live-per-session-phase-progress-is-static-the-gui-never-subscribes) · **Category:** B · **Effort:** S–M · **Priority:** High - **Why:** The create flow is fire-and-await-final-status; nothing consumes `controller.subscribe()`, so progress is static (`active_phase=None`). This consumer @@ -45,10 +55,21 @@ Status legend: `⬜ Not started` · `🟡 In progress` · `✅ Done` · `⛔ Blo - **Acceptance:** the Confirm & Create step advances through the live phases (and the §9.3 per-plugin sub-row when the host emits `progress`); the consumer is cancelled/cleaned up on wizard close; emitted phase strings verified to match the component's `PHASES`. -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** Fixed a confirmed bug: `session_progress.PHASES`/`PHASE_LABELS` used + `post_validation`/`queueing_sync`, but the controller emits the wire-format + `validating_post_creation`/`queueing_nas_sync` (`state_machine.Phase`) — two of six phases would + silently no-op. Corrected the component to the verbatim wire-format (single source of truth) and + updated the phase-order test. Added `SessionProgressState` + `apply_frame(state, frame)` to the + component (folds `phase`/`progress`/`done` frames; ignores `input_required`/unknown). Each wizard + renders the confirm-step bar via a `@ui.refreshable` reading `state.progress`, exposing + `state.progress_refresh`. `mount._run_creation` now consumes `controller.subscribe()` via + `_consume_session_progress` while the pipeline runs (race-free: `_launch` creates the event queue + before the pipeline starts and the asyncio.Queue buffers early phases). Note: the controller + emits no `progress` frame today, so the §9.3 per-plugin sub-row stays dormant until the plugin + host emits one (`apply_frame` handles it for when it lands). Suite green (2205 passed). -### - [ ] T3 — Render the Operations modal + toolbar/footer hooks +### - [x] T3 — Render the Operations modal + toolbar/footer hooks - **Spec:** [§B1](./REMAINING_WORK.md#b1--operations-modal-is-built-exported-and-rendered-nowhere--highest-impact) · **Category:** B · **Effort:** M · **Priority:** High - **Why:** `operations_modal` is built/exported but rendered nowhere; no `[Operations…]` action exists, so sessions can't be inspected or acted on from the GUI. @@ -57,20 +78,38 @@ Status legend: `⬜ Not started` · `🟡 In progress` · `✅ Done` · `⛔ Blo opens the modal populated from `deps.controller.session_store`; footer Sync segment shows the "N need input" state and opens the same modal. - **Depends on:** T2 (for auto-refresh). -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** Added a public `SessionStore.iter_sorted()` accessor + a shared + `project_identifier(request)` (both in `controller/session_store.py`) so the `/operations` route + and the in-process panel stop reaching into `store._sessions` and label rows identically; + refactored the route onto them. Added `OperationRow.from_session()` (maps the §4.7 states onto the + panel's running/suspended/completed buckets). `main.py`: `[Operations…]` toolbar button (shown + when `operations_count > 0`, warning-colored if any need input) + footer Sync segment flips to + "N operations need input" and opens the same modal; `MainPageState` gains + `operations_count`/`operations_input_required`. `mount.py`: `_operation_counts`, + `_build_operation_rows`, `_open_operations_modal` (fresh snapshot per open), and an + `_open_operation_details` "view log" dialog. Fixed a latent circular import by importing + `SessionState`/`project_identifier` from submodules in `operations.py`. Resume/cancel row actions + are placeholders here (cancel does a keep-files cancel) — fully wired in T4/T5. Suite green. -### - [ ] T4 — Wire Resume / Cancel (+ §9.4 confirm, §9.6 disable rule) +### - [x] T4 — Wire Resume / Cancel (+ §9.4 confirm, §9.6 disable rule) - **Spec:** [§B2](./REMAINING_WORK.md#b2--no-gui-path-to-resume-or-cancel-a-session) · **Category:** B · **Effort:** M · **Priority:** High - **Key files:** `controller/creation.py:327,337-341,351,377-381,1151-1153` · `ui/mount.py:1036` (dispatch pattern) · `ui/pages/main.py:54,198-200` - **Acceptance:** modal Resume/Cancel call `controller.resume`/`cancel` in-process; Cancel routes through a §9.4 Discard/Keep dialog mapped to `discard_files`; creation buttons disabled while a session is non-terminal (single-equipment); 409/`ValueError` surfaced. - **Depends on:** T3. -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** `_cancel_operation` now opens a §9.4 Discard / Keep dialog mapped to + `controller.cancel(id, discard_files=…)` (Discard → `shutil.rmtree` of the partial dir), with + errors surfaced as a toast (`cancel` is a no-op on an already-terminal session, so no 409 to + raise). §9.6 single-equipment lock: `_operation_counts` now also returns an `active` + (strictly non-terminal) count; `_build_main_state` sets `MainPageState.creation_in_flight`, and + `main.py` disables New Project / Run / Test Run (with a tooltip) while a creation is in flight. + Added `_operation_counts` tests (panel vs active vs input_required). Resume still routes through + T5's input dialog. Suite green (UI 463 passed). -### - [ ] T5 — Plugin `INPUT_REQUIRED` escalation dialog (new component) +### - [x] T5 — Plugin `INPUT_REQUIRED` escalation dialog (new component) - **Spec:** [§B3](./REMAINING_WORK.md#b3--plugin-input_required-cannot-be-answered-from-the-gui) · **Category:** B · **Effort:** M · **Priority:** High - **Why:** When a plugin pauses, the wizard hangs indefinitely with no dialog; only the HTTP route can answer. @@ -79,87 +118,145 @@ Status legend: `⬜ Not started` · `🟡 In progress` · `✅ Done` · `⛔ Blo reason/plugin header; Submit → `controller.resume(sid, values)`, Cancel → §9.4 confirm → `cancel`; escalations strictly sequential (§9.2); handles `failed`/timeout force-close. - **Depends on:** T2 (detect INPUT_REQUIRED) + T3 (surface). -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** New component `ui/components/input_required_dialog.py`: §9.1 "Additional input + required" dialog with a plugin-identity pill, the reason line, and one widget per + `pending_input["fields"]` (string→input, text→textarea, choice→select, boolean→checkbox), + two-way bound to a values dict; `persistent` so the escalation must resolve before the next frame + (§9.2). `mount.py`: the T2 consumer now opens it on an `input_required` frame and force-closes it + on a terminal `done`/`failed` frame (plugin timeout); Submit → `controller.resume(sid, values)` + (errors — empty/invalid payload, stale state, or plugin re-rejection re-emitting `input_required` + — surfaced as a toast / re-opened dialog), Cancel → the §9.4 cancel dialog. `_cancel_operation` + was split into a controller-driven `_cancel_session` core (reused by the dialog) + a deps + resolver; the Operations modal's Resume (`_resume_operation`) reads the parked `pending_input` and + re-opens the same dialog. Tests for `collect_default_values` + dialog build. Suite green (2212). --- ## Phase 3 — Visibility & usability -### - [ ] T6 — Live Problems/audit stream + real counts +### - [x] T6 — Live Problems/audit stream + real counts - **Spec:** [§B5](./REMAINING_WORK.md#b5--audit--problems-live-stream-not-consumed-by-the-in-process-ui) · **Category:** B · **Effort:** M · **Priority:** Med - **Key files:** `api/app.py:77-133,266,327-356` · `ui/pages/problems.py:279-281` · `ui/pages/main.py:50-51,432,445-450` · `ui/mount.py:699-721,1579-1588` - **Acceptance:** live findings counts (tab badge + right-pane summary, single source); "Last audit: HH:MM:SS · Next refresh in Ns" instead of `--`; override-and-allow-sync dialog (§11.5) reachable; `start_audit_task=True` confirmed in the production app build. - **Depends on:** T2 (subscription pattern, optional). -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** The 30 s `_audit_loop` now caches tier counts on + `deps.last_audit_hard`/`last_audit_soft` (single source, no per-render re-audit, per the §B5 + preference); `_build_main_state` reads them into `MainPageState.problems_count_hard/soft`, so the + Problems **tab badge** (`problems_badge_text`) and the **right-pane summary** are now real (the + right-pane no longer hardcodes "Showing 0"). The `/problems` footer takes `last_audit_at` and + renders "Last audit: HH:MM:SS · Next refresh in Ns" with a 1 s `ui.timer` countdown (was a + hardcoded "--"); `start_audit_task=True` confirmed in the production tray build. + **Deferred (documented):** wiring the §11.5 override-and-allow-sync action and the full live + WS-delta stream are out of scope here because `render_problems_page` renders a *view-model* shape + (`finding.severity/.path/.state/.finding_id`) that the raw `Validator` `Finding` + (`tier/rule/run_path/…`) does not provide — a pre-existing mismatch (the render is e2e-only, + `# pragma: no cover`). Reconciling that view-model + the in-process override-write path + (mirroring `POST /problems/{run_path}/override`) is a follow-up; the counts/last-audit core lands + here. Suite green. -### - [ ] T7 — Operators allowlist chip editor +### - [x] T7 — Operators allowlist chip editor - **Spec:** [§A4](./REMAINING_WORK.md#a4--operators-allowlist-has-backend-enforcement-but-no-editor-ui) · **Category:** A · **Effort:** M · **Priority:** Med - **Key files:** `ui/pages/settings.py:25,30-32,46,464` · `config/models.py:422-427` · `controller/creation.py:540-547` - **Acceptance:** an Operators section with a chip editor bound to `draft.operators.allowlist`, persisting via the existing draft path; **case-sensitive**, no trimming/lowercasing; kept **non-gating** (not added to `_missing_setup_sections`). - **Builds:** the reusable chip/list widget (shared with T10). -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** Built a reusable `_render_chip_editor(values, …)` in `settings.py` (modeled on the + equipment add-form: add/delete/optional-reset, mutates the draft list in place so persistence + rides the existing draft → finalize → Save path). Added `"operators"` to `SETTINGS_SECTIONS` + (between `nas_cleanup` and `validator`) + `SECTION_TITLES`, and an `elif section == "operators"` + branch with the §7.9 helper text + chip editor bound to `draft.operators.allowlist`. Stored + verbatim (case-sensitive; whitespace trimmed on add only). Non-gating (not in + `_missing_setup_sections`). Updated the section-count test (8→9). Suite green. -### - [ ] T8 — "Start at login" checkbox wiring +### - [x] T8 — "Start at login" checkbox wiring - **Spec:** [§A2](./REMAINING_WORK.md#a2--start-at-login-autostart-checkbox-is-not-bound) · **Category:** A · **Effort:** S · **Priority:** Med - **Key files:** `ui/pages/settings.py:514` · `tray/dependencies.py:130,840` (add `autostart_is_registered`) · `ui/mount.py:687` - **Acceptance:** checkbox reflects real `is_registered()` on open; `on_change` toggles immediately via `deps.autostart_toggle` and reflects the actual post-op result (reverts on failure); sandboxed in tests via `EXLAB_AUTOSTART_ROOT`. -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** `tray/dependencies.py` seeds `deps.autostart_is_registered` from + `AutostartManager().is_registered()` at build. `_apply_autostart` now returns the toggle's + real `is_registered()` result (the welcome card still ignores it). `_settings` passes + `autostart_registered` + `on_set_autostart`; the Application checkbox (exempt from the draft, + §7.13) seeds from the real state and, on change, applies immediately and reverts to the actual + post-op result on mismatch (re-entrancy-guarded). Disabled when no toggle is wired. Tests cover + the return-value relay. -### - [ ] T9 — "Quit ExLab-Wizard now" button (+ `deps` quit hook) +### - [x] T9 — "Quit ExLab-Wizard now" button (+ `deps` quit hook) - **Spec:** [§A1](./REMAINING_WORK.md#a1--quit-exlab-wizard-now-button-has-no-handler) · **Category:** A · **Effort:** S · **Priority:** Low–Med - **Key files:** `ui/pages/settings.py:516` · `tray/main.py:77,80,84,171` · `tray/quit_coordinator.py:82` · `ui/mount.py:358` - **Acceptance:** a `deps.request_quit` hook exists; button triggers graceful shutdown **scheduled non-blocking** (response flushes first) behind a confirm; no-op/disabled when the hook is absent (headless/tests). -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** The tray builder (`tray/main.py`) attaches `deps.request_quit = + tray_app.request_quit` after building `TrayApp`. `_settings` builds an `on_quit` that schedules + the hook via `ui.timer(0.1, …, once=True)` so the HTTP response flushes before the server tears + down, and passes it to the Application section, which renders the Quit button behind a confirm + dialog (§3.4.6). Disabled when the hook is absent (headless/tests). `AppDependencies` gains the + typed `request_quit` field. -### - [ ] T10 — `content_scan_extensions` chip editor + reset-to-defaults +### - [x] T10 — `content_scan_extensions` chip editor + reset-to-defaults - **Spec:** [§A5](./REMAINING_WORK.md#a5--content_scan_extensions-renders-read-only-partial) · **Category:** A · **Effort:** S · **Priority:** Low - **Key files:** `ui/pages/settings.py:482-484` · `config/models.py:435,464-471` - **Acceptance:** chip editor bound to `draft.validator.content_scan_extensions` with a `[Reset to defaults]` action; entries validated to start with `.` on add. - **Depends on:** T7 (reuse the chip widget). -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** Replaced the read-only "Scanned file extensions" label with the shared + `_render_chip_editor` bound to `draft.validator.content_scan_extensions`, with a + `[Reset to defaults]` action (`draft.validator.content_scan_extensions[:] = + _default_content_scan_extensions()`) and an on-add validator rejecting entries that don't start + with `.` (the `ValidatorConfig` dot-prefix rule also re-checks at Save). Landed with T7. -### - [ ] T11 — Application-section status labels parity (§7.13) +### - [x] T11 — Application-section status labels parity (§7.13) - **Spec:** [§A3](./REMAINING_WORK.md#a3--application-section-status-indicators-are-hardcoded--missing-713-parity) · **Category:** A · **Effort:** S · **Priority:** Low - **Key files:** `ui/pages/settings.py:515` - **Acceptance:** tray-availability label reflects real `pystray` init; window-on-close behavior text present. -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** The tray builder sets `deps.tray_available` from `_tray_backend_available()` + (whether `pystray` imports; window-only per §15.7.4 otherwise). The Application section now shows + "Show in system tray: available / unavailable (window-only)" from that flag (no longer a static + literal) plus the window-on-close behavior copy. `AppDependencies` gains the typed + `tray_available` field. Landed with T8/T9. --- ## Phase 4 — Cleanups (anytime) -### - [ ] T12 — Resolve the offline-catalogue version-policy TODO +### - [x] T12 — Resolve the offline-catalogue version-policy TODO - **Spec:** [§D1](./REMAINING_WORK.md#d1--offline-catalogue-schema-version-gate-is-stricter-than-the-cache-file-policy-open-spec-question) · **Category:** D · **Effort:** S · **Priority:** Low - **Key files:** `lims/catalogue.py:93-103` - **Acceptance:** policy decided (exact vs §11.9.2 major-only) and the spec updated; check aligned; `TODO(spec)` removed. -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** Policy = **treat as absent / WARN** (user-confirmed; §7.2.9.3). `read_catalogue` + now returns `OfflineCatalogue | None` — a `schema_version` mismatch logs a WARN and returns + `None` (consumer falls through) instead of raising `ConfigError`; the endpoint mismatch and + missing/parse-error cases stay hard errors. The decision is recorded in the function docstring; + `TODO(spec)` removed. Caller (`mount._lims_projects_from_catalogue`) treats `None` as `[]`. Test + flipped to assert `None` + WARN. -### - [ ] T13 — Delete the stale plugin-registry comment +### - [x] T13 — Delete the stale plugin-registry comment - **Spec:** [§D2](./REMAINING_WORK.md#d2--stale-pluginsregistrypy-not-yet-committed-comment-documentation-only) · **Category:** D · **Effort:** XS · **Priority:** Trivial - **Key files:** `plugins/host.py:73-81,115-123,896-905` - **Acceptance:** comments rewritten to state `registry.py` is committed + wired and `_ListBackedRegistry` is test-only. -- **Status:** ⬜ Not started -- **Impl note:** _(pending)_ +- **Status:** ✅ Done +- **Impl note:** Rewrote the three "owned by Agent A / not yet committed" comment blocks in + `plugins/host.py` (the registry-surface header, the `PluginRegistryProtocol` docstring, and the + `_ListBackedRegistry`/`build_test_registry` block) to state that + `plugins.registry.PluginRegistry` is the committed, wired production registry (built in + `tray.dependencies._build_plugin_host`) and that `_ListBackedRegistry` is test-only. Also fixed a + stray "before Agent A's msgspec.Struct lands" comment. Pure cleanup; no behavior change. --- @@ -170,4 +267,4 @@ Status legend: `⬜ Not started` · `🟡 In progress` · `✅ Done` · `⛔ Blo between `api/routers/operations.py` and the in-process modal. ## Progress -0 / 13 complete. +13 / 13 complete. diff --git a/src/exlab_wizard/api/app.py b/src/exlab_wizard/api/app.py index bace991..2c85830 100644 --- a/src/exlab_wizard/api/app.py +++ b/src/exlab_wizard/api/app.py @@ -44,7 +44,7 @@ from exlab_wizard.api.routers.staging import build_staging_router from exlab_wizard.api.setup import build_setup_router from exlab_wizard.config.models import Config -from exlab_wizard.constants import AUDIT_REFRESH_SECONDS, AuditScopeKind +from exlab_wizard.constants import AUDIT_REFRESH_SECONDS, AuditScopeKind, Tier from exlab_wizard.logging import get_logger from exlab_wizard.utils.time import utc_now_iso @@ -218,6 +218,10 @@ class AppDependencies: # Audit / pub-sub --------------------------------------------------- audit_channel: AuditChannel | None = None last_audit_at: str | None = None + # Tier counts from the latest background audit pass, read by the GUI + # Problems tab badge + right-pane summary (T6 / §B5). + last_audit_hard: int = 0 + last_audit_soft: int = 0 # Health snapshot probes ------------------------------------------- nas_sync_snapshot: Callable[[], dict[str, Any]] | None = None @@ -229,6 +233,14 @@ class AppDependencies: lims_probe: Callable[..., Any] | None = None equipment_probe: Callable[..., Any] | None = None autostart_toggle: Callable[[bool], Any] | None = None + # Real platform autostart-registration state, seeded at tray build so + # Settings -> Application can reflect it (T8). + autostart_is_registered: bool = False + # Graceful-shutdown hook + tray-availability flag, attached by the tray + # builder so the in-window Settings -> Application section can quit (T9) + # and show real tray status (T11). Absent in headless / server-only runs. + request_quit: Callable[[], None] | None = None + tray_available: bool = False # Background tasks -------------------------------------------------- audit_task: asyncio.Task[None] | None = field(default=None, repr=False) @@ -343,6 +355,16 @@ async def _audit_loop(deps: AppDependencies, interval_seconds: float) -> None: continue audit_at = utc_now_iso() deps.last_audit_at = audit_at + # Cache tier counts so the in-process GUI (Problems tab badge + + # right-pane summary) reads them straight off deps -- a single + # source, refreshed on the 30 s cadence -- without re-running a + # full O(tree) audit on every page render (T6 / §B5). + deps.last_audit_hard = sum( + 1 for f in findings if getattr(f, "tier", "") == Tier.HARD.value + ) + deps.last_audit_soft = sum( + 1 for f in findings if getattr(f, "tier", "") == Tier.SOFT.value + ) added, removed, changed = _diff_findings(last, findings) if deps.audit_channel is not None: if not last: diff --git a/src/exlab_wizard/api/routers/operations.py b/src/exlab_wizard/api/routers/operations.py index 8d38445..28a13db 100644 --- a/src/exlab_wizard/api/routers/operations.py +++ b/src/exlab_wizard/api/routers/operations.py @@ -21,7 +21,13 @@ from exlab_wizard.api._dependencies import require_controller from exlab_wizard.api.setup import setup_state_gate -from exlab_wizard.controller import SessionState + +# Import from the submodules (not the ``exlab_wizard.controller`` package) +# to avoid a circular import: ``api.app`` pulls in this router while the +# controller package's ``__init__`` is still initializing, so reading +# attributes off the partially-built package would fail. +from exlab_wizard.controller.session_store import on_operations_panel, project_identifier +from exlab_wizard.controller.state_machine import SessionState from exlab_wizard.utils.time import dt_to_iso __all__ = ["OperationEntry", "OperationsResponse", "build_operations_router"] @@ -63,14 +69,11 @@ async def list_operations(request: Request) -> OperationsResponse: controller = require_controller(request) sessions = controller.session_store operations: list[OperationEntry] = [] - # SessionStore exposes a private ``_sessions`` dict; iterate - # explicitly rather than reaching into the dict so the public - # surface stays narrow. - for sid, session in _iter_sessions(sessions): - if session.state in (SessionState.DONE, SessionState.ABORTED): - # Terminal-success and explicit-cancel rows fall off - # the panel; FAILED rows stay so the operator can see - # the recent failure. + for sid, session in sessions.iter_sorted(): + # Terminal-success and explicit-cancel rows fall off the panel; + # FAILED rows stay so the operator can see the recent failure + # (Frontend §9.5 -- the shared membership rule). + if not on_operations_panel(session): continue operations.append(_session_to_entry(sid, session)) return OperationsResponse(operations=operations) @@ -83,23 +86,6 @@ async def list_operations(request: Request) -> OperationsResponse: # --------------------------------------------------------------------------- -def _iter_sessions(store: Any) -> list[tuple[str, Any]]: - """Return ``(session_id, session)`` pairs from the store. - - The :class:`SessionStore` keeps its dict private; we use the - documented contract that ``store._sessions`` is a ``dict``. A - public accessor would be cleaner; until that lands the shim here - is the single touchpoint. - """ - sessions = getattr(store, "_sessions", {}) - if not isinstance(sessions, dict): - return [] - return sorted( - sessions.items(), - key=lambda pair: getattr(pair[1], "created_at", None) or 0, - ) - - def _session_to_entry(session_id: str, session: Any) -> OperationEntry: request = session.request plugin_name: str | None = None @@ -114,28 +100,8 @@ def _session_to_entry(session_id: str, session: Any) -> OperationEntry: else str(session.state), started_at=dt_to_iso(session.created_at) if session.created_at is not None else "", equipment_id=getattr(request, "equipment_id", None), - project_short_id=_project_short_id(request), + project_short_id=project_identifier(request), run_label=getattr(request, "label", None), plugin_name=plugin_name, suspended_reason=suspended_reason, ) - - -def _project_short_id(request: Any) -> str | None: - """Pluck a project identifier off a project / run request. - - A project request carries the LIMS ``short_id`` in its - ``lims_project`` block; a run request carries only the parent - project's folder name (the human-readable LIMS name, Backend Spec - §3.2), so that name is used as the operations-panel identifier. - """ - short = getattr(request, "project_short_id", None) - if short: - return short - lims_project = getattr(request, "lims_project", None) - if isinstance(lims_project, dict): - value = lims_project.get("short_id") - if isinstance(value, str) and value: - return value - project_name = getattr(request, "project_name", None) - return project_name if isinstance(project_name, str) and project_name else None diff --git a/src/exlab_wizard/controller/__init__.py b/src/exlab_wizard/controller/__init__.py index 4bc23c9..b7d480c 100644 --- a/src/exlab_wizard/controller/__init__.py +++ b/src/exlab_wizard/controller/__init__.py @@ -18,7 +18,12 @@ RunCreateRequest, SessionHandle, ) -from exlab_wizard.controller.session_store import Session, SessionStore +from exlab_wizard.controller.session_store import ( + Session, + SessionStore, + on_operations_panel, + project_identifier, +) from exlab_wizard.controller.state_machine import ( VALID_TRANSITIONS, Phase, @@ -43,5 +48,7 @@ "SessionState", "SessionStore", "assert_transition", + "on_operations_panel", + "project_identifier", "state_to_phase", ] diff --git a/src/exlab_wizard/controller/creation.py b/src/exlab_wizard/controller/creation.py index 2351399..edd207c 100644 --- a/src/exlab_wizard/controller/creation.py +++ b/src/exlab_wizard/controller/creation.py @@ -33,6 +33,8 @@ import asyncio import contextlib +import getpass +import os import shutil import socket from collections.abc import AsyncIterator @@ -65,6 +67,7 @@ OBJECTIVE_MAX_LENGTH, README_FILE_NAME, CreationLevel, + FieldType, LIMSProjectSource, PluginStatus, RunKind, @@ -90,11 +93,19 @@ compose_run_path, creation_json_path, equipment_json_path, + readme_fields_json_path, validate_project_name, ) from exlab_wizard.plugins.base import PluginContext from exlab_wizard.plugins.host import InputRequiredPayload, PluginHost, PluginPassResult from exlab_wizard.plugins.logger import HostPluginLogger +from exlab_wizard.readme import ( + CoreFields, + CustomField, + ReadmeContext, + SystemFields, + TemplateFieldDecl, +) from exlab_wizard.template.copier_driver import ( CORE_README_FIELD_IDS, RenderResult, @@ -185,40 +196,31 @@ class SessionHandle: # --------------------------------------------------------------------------- -@dataclass(frozen=True) -class ReadmeContext: - """Inputs handed to the README generator. Phase 8 owns the canonical type; - this lightweight stand-in lets Phase 7 ship before Phase 8 lands.""" - - label: str - operator: str - objective: str - equipment_id: str - project_short_id: str - run_kind: str - variables: dict[str, Any] - template: ResolvedTemplate - extra_fields: dict[str, Any] = field(default_factory=dict) - - class ReadmeGeneratorProtocol(Protocol): - """The README generator surface the controller depends on. Phase 8.""" + """The README generator surface the controller depends on. Backend §10. + + Mirrors :meth:`exlab_wizard.readme.ReadmeGenerator.generate`: validate + the composed :class:`~exlab_wizard.readme.ReadmeContext`, write both + ``README.md`` and ``readme_fields.json``, and return ``(readme, cache)``. + """ - async def generate(self, dst: Path, ctx: ReadmeContext) -> Path: ... + async def generate(self, dst: Path, ctx: ReadmeContext) -> tuple[Path, Path]: ... class NoOpReadmeGenerator: - """Minimal README generator used until Phase 8 lands the real one. + """Lightweight README generator for tests / headless fixtures. - Writes a tiny ``README.md`` containing only the core fields so the - post-validate pass has something to scan. + Writes a tiny ``README.md`` containing only the core fields -- enough + for the post-validate pass to scan -- and reports the cache path + without performing the full §10 field merge or validation. Production + injects the real :class:`~exlab_wizard.readme.ReadmeGenerator`. """ - async def generate(self, dst: Path, ctx: ReadmeContext) -> Path: + async def generate(self, dst: Path, ctx: ReadmeContext) -> tuple[Path, Path]: readme = dst / README_FILE_NAME - body = f"# {ctx.label}\n\nOperator: {ctx.operator}\n\n{ctx.objective}\n" + body = f"# {ctx.core.label}\n\nOperator: {ctx.core.operator}\n\n{ctx.core.objective}\n" readme.write_text(body, encoding="utf-8") - return readme + return readme, readme_fields_json_path(dst) class NASSyncProtocol(Protocol): @@ -893,6 +895,71 @@ async def on_input_required(payload: InputRequiredPayload) -> dict[str, Any] | N on_input_required=on_input_required, ) + def _build_readme_context( + self, + *, + req: ProjectCreateRequest | RunCreateRequest, + resolved: ResolvedTemplate, + dst: Path, + ) -> ReadmeContext: + """Compose the §10 four-layer :class:`ReadmeContext` for ``req``. + + Maps the template's ``_exlab_readme.fields`` and the config + ``readme.defaults`` into typed field declarations, partitions the + operator-supplied ``readme_extra`` values across the template / + config / custom layers by id, and fills the auto-managed system + block (Backend Spec §10.6). Reads ``self._config`` at call time so + a live settings reload is reflected on the next creation. + """ + template_decls = _readme_decls_from_template(resolved.extra_readme_fields) + config_decls = _readme_decls_from_config(self._config.readme.defaults) + template_ids = {decl.id for decl in template_decls} + config_ids = {decl.id for decl in config_decls} + + template_fields: dict[str, Any] = {} + config_fields: dict[str, Any] = {} + custom_fields: list[CustomField] = [] + for key, value in req.readme_extra.items(): + if key in template_ids: + template_fields[key] = value + elif key in config_ids: + config_fields[key] = value + elif key in CORE_README_FIELD_IDS: + # Core fields live in their own layer; never echoed as custom. + continue + else: + custom_fields.append( + CustomField(label=key, value="" if value is None else str(value)) + ) + + is_run = isinstance(req, RunCreateRequest) + equipment = next( + (entry for entry in self._config.equipment if entry.id == req.equipment_id), + None, + ) + system = SystemFields( + created=utc_now(), + created_by=_os_username(), + equipment={"id": req.equipment_id, "label": equipment.label if equipment else ""}, + template={"name": resolved.name, "version": resolved.exlab_version}, + # §10.6: ``project`` is the machine-safe LIMS short id recorded in + # README metadata (§3.1) -- distinct from the human-readable + # ``/`` folder segment. ``run`` is the run directory name. + project=self._short_id_for(req), + run=dst.name if is_run else None, + run_kind=self._run_kind_value_for(req) if is_run else "", + ) + return ReadmeContext( + level=CreationLevel.RUN if is_run else CreationLevel.PROJECT, + core=CoreFields(label=req.label, operator=req.operator, objective=req.objective), + template_fields=template_fields, + config_fields=config_fields, + custom_fields=custom_fields, + system=system, + template_field_decls=template_decls, + config_field_decls=config_decls, + ) + async def _write_cache( self, *, @@ -904,19 +971,10 @@ async def _write_cache( plugin_result: PluginPassResult, ) -> CreationJson: """Write README + creation.json into the destination tree.""" - # Render README via the (Phase 8) generator. - readme_ctx = ReadmeContext( - label=req.label, - operator=req.operator, - objective=req.objective, - equipment_id=req.equipment_id, - project_short_id=self._short_id_for(req), - run_kind=(req.run_kind.value if isinstance(req, RunCreateRequest) else "project"), - variables=dict(req.variables), - template=resolved, - extra_fields=dict(req.readme_extra), - ) - await self._readme_generator.generate(dst, readme_ctx) + # Render README.md + readme_fields.json via the §10 generator. + readme_ctx = self._build_readme_context(req=req, resolved=resolved, dst=dst) + readme_path, readme_cache_path = await self._readme_generator.generate(dst, readme_ctx) + _log.debug("README written: %s (cache: %s)", readme_path, readme_cache_path) # Build the CreationJson payload. get_cache_dir(dst).mkdir(parents=True, exist_ok=True) @@ -1208,6 +1266,76 @@ def _required_field_ids(extra_fields: list[dict[str, Any]]) -> tuple[str, ...]: return tuple(out) +def _readme_decls_from_template(entries: list[dict[str, Any]]) -> list[TemplateFieldDecl]: + """Map a template's ``_exlab_readme.fields`` dicts to typed declarations. + + Entries without a string ``id`` are skipped (mirrors + :func:`_required_field_ids`); ``type`` is coerced to + :class:`~exlab_wizard.constants.FieldType` so the generator can + type-check values against it. An unknown ``type`` raises ``ValueError``, + which the pipeline surfaces as a failed creation. + """ + decls: list[TemplateFieldDecl] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + fid = entry.get("id") + if not isinstance(fid, str) or not fid: + continue + options = entry.get("options") + hint = entry.get("hint") + decls.append( + TemplateFieldDecl( + id=fid, + label=str(entry.get("label", fid)), + type=FieldType(str(entry.get("type", FieldType.STRING.value))), + required=bool(entry.get("required", False)), + default=entry.get("default", ""), + options=list(options) if isinstance(options, list) else None, + hint=hint if isinstance(hint, str) else None, + ) + ) + return decls + + +def _readme_decls_from_config(defaults: list[Any]) -> list[TemplateFieldDecl]: + """Map ``config.readme.defaults`` entries to typed declarations. + + Core field ids are dropped -- they are backend-managed and live in + their own layer (Backend Spec §10.3), matching the required-field gate + in :meth:`CreationController._validate_inputs`. + """ + decls: list[TemplateFieldDecl] = [] + for entry in defaults: + if entry.id in CORE_README_FIELD_IDS: + continue + decls.append( + TemplateFieldDecl( + id=entry.id, + label=entry.label, + type=entry.type, + required=entry.required, + default=entry.default, + options=list(entry.options) if entry.options else None, + hint=entry.hint, + ) + ) + return decls + + +def _os_username() -> str: + """Return the creating OS user for the README ``system.created_by``. + + Distinct from the experiment ``operator`` (Backend Spec §10.6). Falls + back to the ``USER`` / ``USERNAME`` environment variables and finally + ``"unknown"`` when the platform cannot report a login name. + """ + try: + return getpass.getuser() + except Exception: + return os.environ.get("USER") or os.environ.get("USERNAME") or "unknown" + + def _has_hard_finding(findings: list[Finding]) -> bool: return any(f.tier == Tier.HARD.value for f in findings) diff --git a/src/exlab_wizard/controller/session_store.py b/src/exlab_wizard/controller/session_store.py index be59870..cb714cc 100644 --- a/src/exlab_wizard/controller/session_store.py +++ b/src/exlab_wizard/controller/session_store.py @@ -126,6 +126,15 @@ def get(self, session_id: str) -> Session | None: """Return the session keyed by ``session_id``, or ``None``.""" return self._sessions.get(session_id) + def iter_sorted(self) -> list[tuple[str, Session]]: + """Return ``(session_id, session)`` pairs, oldest-created first. + + The public, narrow read accessor over the otherwise-private + session map: used by the ``/operations`` route and the in-process + Operations panel so neither reaches into ``_sessions`` directly. + """ + return sorted(self._sessions.items(), key=lambda pair: pair[1].created_at) + def transition(self, session_id: str, new_state: SessionState) -> None: """Move ``session_id`` to ``new_state``, updating ``current_phase``. @@ -232,11 +241,48 @@ def _gc_once(self, gc_age: timedelta) -> None: continue with suppress(ValueError): self.transition(session_id, SessionState.ABORTED) - self.close( - session_id, - {"code": "session_abandoned", "reason": "no client heartbeat for >1h"}, - ) + outcome = {"code": "session_abandoned", "reason": "no client heartbeat for >1h"} + self.close(session_id, outcome) + # Publish a terminal frame so any live ``subscribe()`` consumer + # (the in-process wizard progress loop) wakes and exits instead + # of parking forever on ``queue.get()`` -- this GC path bypasses + # the controller's ``_publish``, so nothing else closes the queue. + if session.event_queue is not None: + with suppress(Exception): + session.event_queue.put_nowait({"kind": "failed", "error": outcome}) _log.info( "session GC closed abandoned INPUT_REQUIRED session", extra={"context": {"session_id": session_id}}, ) + + +def on_operations_panel(session: Session) -> bool: + """Return ``True`` when ``session`` belongs on the Operations panel. + + Frontend §9.5 membership rule, defined once: everything except the + terminal ``DONE`` / ``ABORTED`` (``FAILED`` stays so a recent failure + remains visible). Shared by the ``/operations`` route and the in-process + panel so the rule can't drift between the HTTP and GUI surfaces. + """ + return session.state not in (SessionState.DONE, SessionState.ABORTED) + + +def project_identifier(request: Any) -> str | None: + """Pluck a project identifier off a project / run creation request. + + A project request carries the LIMS ``short_id`` in its ``lims_project`` + block; a run request carries the parent project's folder name (the + human-readable LIMS name, Backend Spec §3.2). Shared by the + ``/operations`` route and the in-process Operations panel so both label + rows identically. + """ + short = getattr(request, "project_short_id", None) + if short: + return short + lims_project = getattr(request, "lims_project", None) + if isinstance(lims_project, dict): + value = lims_project.get("short_id") + if isinstance(value, str) and value: + return value + project_name = getattr(request, "project_name", None) + return project_name if isinstance(project_name, str) and project_name else None diff --git a/src/exlab_wizard/lims/catalogue.py b/src/exlab_wizard/lims/catalogue.py index 922fbc0..3050ca7 100644 --- a/src/exlab_wizard/lims/catalogue.py +++ b/src/exlab_wizard/lims/catalogue.py @@ -52,8 +52,9 @@ class OfflineCatalogue: """Decoded offline catalogue. Backend Spec §7.2.9.1. ``schema_version`` is pinned to the constant declared in - :mod:`exlab_wizard.constants.schema_versions`; mismatches surface - as :class:`exlab_wizard.errors.ConfigError`. + :mod:`exlab_wizard.constants.schema_versions`; a mismatch is treated + as catalogue-absent (WARN + ``None``) per §7.2.9.3 -- see + :func:`read_catalogue`. ``lims_endpoint`` is verified by :func:`read_catalogue` against the consumer's configured LIMS endpoint; mismatches are rejected per @@ -68,18 +69,25 @@ class OfflineCatalogue: projects: list[LIMSProject] -def read_catalogue(path: Path, *, expected_endpoint: str) -> OfflineCatalogue: +def read_catalogue(path: Path, *, expected_endpoint: str) -> OfflineCatalogue | None: """Read and validate the catalogue file. - Raises :class:`exlab_wizard.errors.ConfigError` on any of: + Returns ``None`` (and logs a WARN) when the ``schema_version`` does not + match :data:`exlab_wizard.constants.OFFLINE_CATALOGUE_VERSION`: per + Backend Spec §7.2.9.3 a version mismatch is treated as *catalogue + absent* (the consumer falls through to its next picker source), rather + than a hard error. (Policy decision, 2026-05-29: the catalogue follows + the "treat as absent / WARN" rule of §7.2.9.3; it is deliberately *not* + the §11.9.2 major-only cache-file gate.) + + Raises :class:`exlab_wizard.errors.ConfigError` on the genuine-error + cases: - file missing / unreadable - JSON parse error - - ``schema_version`` is not the constant - :data:`exlab_wizard.constants.OFFLINE_CATALOGUE_VERSION` - - ``lims_endpoint`` differs from ``expected_endpoint`` (per - §7.2.9.3 the producer's LIMS must match the consumer's - configuration; cross-lab leakage is rejected, not warned). + - ``lims_endpoint`` differs from ``expected_endpoint`` (per §7.2.9.3 + the producer's LIMS must match the consumer's configuration; + cross-lab leakage is rejected, not warned). """ try: decoded = read_msgspec_json_raw(Path(path)) @@ -90,17 +98,19 @@ def read_catalogue(path: Path, *, expected_endpoint: str) -> OfflineCatalogue: msg = f"offline catalogue at {path} is not valid JSON: {exc}" raise ConfigError(msg) from exc - # TODO(spec): the OFFLINE_CATALOGUE_VERSION check below is an - # exact-match (major+minor); this is intentionally stricter than the - # §11.9.2 major-only gate used for cache files. Revisit once the spec - # clarifies whether offline catalogues should follow the same policy. + # §7.2.9.3: a schema_version mismatch is treated as "catalogue absent" + # (WARN + fall through), not a hard error -- so a future producer bump + # never crashes a consumer; it simply ignores the unreadable catalogue. schema_version = decoded.get("schema_version") if schema_version != OFFLINE_CATALOGUE_VERSION: - msg = ( - f"offline catalogue at {path} has schema_version " - f"{schema_version!r}; expected {OFFLINE_CATALOGUE_VERSION!r}" + logger.warning( + "offline catalogue at %s has schema_version %r; expected %r -- " + "treating as absent (§7.2.9.3)", + path, + schema_version, + OFFLINE_CATALOGUE_VERSION, ) - raise ConfigError(msg) + return None lims_endpoint = decoded.get("lims_endpoint", "") if lims_endpoint != expected_endpoint: diff --git a/src/exlab_wizard/plugins/host.py b/src/exlab_wizard/plugins/host.py index 119e1ed..6728e66 100644 --- a/src/exlab_wizard/plugins/host.py +++ b/src/exlab_wizard/plugins/host.py @@ -74,11 +74,12 @@ # Registry surface used by the host. # --------------------------------------------------------------------------- # -# ``plugins/registry.py`` is owned by Agent A and is not yet committed; the -# host only needs the interface, not the implementation. The Protocol -# below defines the minimum surface the host calls into. The -# :class:`PluginRecord` dataclass is what the registry yields per -# resolved plugin -- the registry will eventually ship this same shape. +# The host depends only on the read-only :class:`PluginRegistryProtocol` +# surface below; the concrete implementation is the committed, wired +# :class:`exlab_wizard.plugins.registry.PluginRegistry` (a manifest-scanning, +# lab-wins-merging registry; built and adapted in +# ``tray.dependencies._build_plugin_host``). The :class:`PluginRecord` +# dataclass is what the registry yields per resolved plugin. @dataclass(frozen=True) @@ -115,9 +116,11 @@ class PluginRecord: class PluginRegistryProtocol(Protocol): """Read-only registry surface the host depends on. Backend Spec §6.2. - The concrete implementation lives in ``plugins/registry.py`` (Agent A). - The host only consumes a single method: ``get_record(name)`` which - returns the resolved :class:`PluginRecord` for a registered plugin. + The concrete implementation is + :class:`exlab_wizard.plugins.registry.PluginRegistry` (committed and + wired in ``tray.dependencies._build_plugin_host``). The host consumes a + single method: ``get_record(name)``, returning the resolved + :class:`PluginRecord` for a registered plugin. """ def get_record(self, name: str) -> PluginRecord | None: ... @@ -887,21 +890,20 @@ def _exit_code_to_status(exit_code: int, files_affected: list[str]) -> str: # A tiny convenience adapter for callers (and the integration tests) that -# want to materialize a registry from a list of records inline. The real -# registry implementation will replace this; we ship it here for now so -# Phase 6B's tests can drive the host without depending on Agent A's -# ``plugins/registry.py``. +# want to materialize a registry from a list of records inline. This is a +# test-only helper; production uses the committed +# :class:`exlab_wizard.plugins.registry.PluginRegistry` (wired in +# ``tray.dependencies._build_plugin_host``). @dataclass class _ListBackedRegistry: """Minimal :class:`PluginRegistryProtocol` implementation used for tests. - The production registry (Backend Spec §6.2.1, ``plugins/registry.py``, - Agent A) replaces this with a manifest-scanning, lab-wins-merging - implementation. Keeping the test adapter here lets the host's - integration suite drive the spawn path against fixture plugins - without prematurely committing to the registry's full surface. + Test-only. Production uses the committed, manifest-scanning, + lab-wins-merging :class:`exlab_wizard.plugins.registry.PluginRegistry` + (Backend Spec §6.2.1). Keeping this list-backed adapter here lets the + host's integration suite drive the spawn path against fixture plugins. """ records: list[PluginRecord] = field(default_factory=list) @@ -918,8 +920,8 @@ def build_test_registry(records: Iterable[PluginRecord]) -> PluginRegistryProtoc return _ListBackedRegistry(records=list(records)) -# Convenience for callers serializing the result before Agent A's -# msgspec.Struct lands; not used by the host itself. +# Convenience for callers that need a JSON-friendly copy of an applied +# entry; not used by the host itself. def applied_entry_as_json(entry: dict[str, Any]) -> dict[str, Any]: """Return ``entry`` as a JSON-friendly dict (deep-copied).""" return json.loads(json.dumps(entry, default=str)) diff --git a/src/exlab_wizard/tray/dependencies.py b/src/exlab_wizard/tray/dependencies.py index ea48d99..3d46dd7 100644 --- a/src/exlab_wizard/tray/dependencies.py +++ b/src/exlab_wizard/tray/dependencies.py @@ -128,6 +128,12 @@ def build_production_dependencies(state_dir: Path) -> AppDependencies: ) deps.autostart_toggle = _make_autostart_toggle() + # Seed the real registration state so Settings -> Application can show the + # "Start at login" checkbox checked/unchecked to match reality (T8). + try: + deps.autostart_is_registered = AutostartManager().is_registered() + except Exception: + deps.autostart_is_registered = False # Rclone-only NAS sync migration (2026-05-26). The per-equipment NAS # password-presence set drives the §4.9 setup gate and the Settings @@ -530,6 +536,7 @@ def _build_controller( msg = "controller requires config + validator + template_engine + cache_creation" raise RuntimeError(msg) from exlab_wizard.controller.creation import CreationController + from exlab_wizard.readme import ReadmeGenerator return CreationController( config=config, @@ -539,6 +546,7 @@ def _build_controller( cache_creation=cache_creation, cache_equipment=cache_equipment, session_store=session_store, + readme_generator=ReadmeGenerator(), ) diff --git a/src/exlab_wizard/tray/main.py b/src/exlab_wizard/tray/main.py index 75e7048..553aa27 100644 --- a/src/exlab_wizard/tray/main.py +++ b/src/exlab_wizard/tray/main.py @@ -174,7 +174,7 @@ def _build_default_components( session_store=getattr(deps, "session_store", None), nas_sync=getattr(deps, "nas_sync", None), ) - return TrayApp( + tray_app = TrayApp( server_runner=server_runner, window_launcher=window_launcher, quit_coordinator=quit_coordinator, @@ -182,6 +182,26 @@ def _build_default_components( notification_bus=notification_bus, autostart=autostart, ) + if deps is not None: + # Expose a quit hook + tray-availability flag on deps so the in-window + # Settings -> Application section can trigger the same graceful + # shutdown as the tray menu (T9) and reflect real tray status (T11). + deps.request_quit = tray_app.request_quit + deps.tray_available = _tray_backend_available() + return tray_app + + +def _tray_backend_available() -> bool: + """True when a ``pystray`` backend can be imported. + + When it cannot (e.g. a headless Linux host with no system tray), the + app runs window-only per Backend Spec §15.7.4; Settings reflects that. + """ + try: + import pystray # noqa: F401 + except Exception: + return False + return True def _parse_argv(argv: list[str] | None) -> argparse.Namespace: diff --git a/src/exlab_wizard/ui/components/__init__.py b/src/exlab_wizard/ui/components/__init__.py index 112f7b9..2fe78f8 100644 --- a/src/exlab_wizard/ui/components/__init__.py +++ b/src/exlab_wizard/ui/components/__init__.py @@ -8,6 +8,7 @@ banner_stack, credential_field, filter_chips, + input_required_dialog, mode_badge, operations_modal, override_badge, @@ -25,6 +26,7 @@ "banner_stack", "credential_field", "filter_chips", + "input_required_dialog", "mode_badge", "operations_modal", "override_badge", diff --git a/src/exlab_wizard/ui/components/input_required_dialog.py b/src/exlab_wizard/ui/components/input_required_dialog.py new file mode 100644 index 0000000..bef0c9c --- /dev/null +++ b/src/exlab_wizard/ui/components/input_required_dialog.py @@ -0,0 +1,125 @@ +"""Plugin ``INPUT_REQUIRED`` escalation dialog (Frontend Spec §9.1). + +When a plugin suspends mid-creation to ask the operator for more input, +the controller publishes an ``input_required`` frame carrying the +plugin identity, a ``reason`` line, and a list of README-style field +declarations. This dialog renders those fields, collects the answers, +and hands them back so the caller can ``controller.resume(...)``. + +Escalations are strictly sequential (§9.2): the dialog is ``persistent`` +so it must be resolved (Submit / Cancel) before anything else happens. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from exlab_wizard.logging import get_logger + +_log = get_logger(__name__) + + +def _field_id(field: dict[str, Any]) -> str | None: + """Return the field's identifier (``id``, falling back to ``key``).""" + fid = field.get("id") or field.get("key") + return fid if isinstance(fid, str) and fid else None + + +def collect_default_values(fields: list[dict[str, Any]]) -> dict[str, Any]: + """Seed an answers dict from each field's declared default. + + Booleans default to ``False`` when undeclared; every other type + defaults to the empty string so :func:`input_required_dialog` always + has a value to two-way bind against. + """ + values: dict[str, Any] = {} + for field in fields: + fid = _field_id(field) + if fid is None: + continue + ftype = str(field.get("type", "string")) + default = field.get("default") + if ftype == "boolean": + values[fid] = bool(default) if default is not None else False + else: + values[fid] = default if default is not None else "" + return values + + +def input_required_dialog( + *, + plugin: str, + reason: str, + fields: list[dict[str, Any]], + on_submit: Callable[[dict[str, Any]], None], + on_cancel: Callable[[], None], +) -> Any: + """Build the §9.1 "Additional input required" dialog. + + Renders one widget per field (string/text/choice/date/boolean), a + plugin-identity pill, and the reason line. ``on_submit`` receives the + collected answers dict; ``on_cancel`` fires when the operator backs + out. Returns the NiceGUI dialog (or, in tests, a payload describing + the resolved fields/defaults). + """ + values = collect_default_values(fields) + payload = {"plugin": plugin, "reason": reason, "fields": fields, "values": values} + + try: + from nicegui import ui + except Exception: + return payload + + # §9.2: persistent so the escalation must be resolved before the next + # frame -- no click-away dismissal that would strand the pipeline. + dialog = ui.dialog().props("persistent") + + def _submit() -> None: + dialog.close() + on_submit(dict(values)) + + def _cancel() -> None: + dialog.close() + on_cancel() + + with ( + dialog, + ui.card().props('data-testid="input-required-dialog"').style("min-width: 480px;"), + ): + ui.label("Additional input required").style( + "font-family: var(--font-display); font-size: var(--text-md); " + "color: var(--color-heading); font-weight: 600;" + ) + ui.label(plugin).props('data-testid="input-required-plugin"').style( + "font-family: var(--font-mono); font-size: var(--text-xs); " + "color: var(--color-heading); background: var(--color-rule); " + "padding: 0.1rem 0.5rem; border-radius: var(--radius-sm); align-self: flex-start;" + ) + if reason: + ui.label(reason).style("color: var(--color-body);") + for field in fields: + fid = _field_id(field) + if fid is None: + continue + label = str(field.get("label", fid)) + ftype = str(field.get("type", "string")) + if ftype == "boolean": + ui.checkbox(label, value=values[fid]).bind_value(values, fid) + elif ftype == "choice": + options = [str(opt) for opt in (field.get("options") or [])] + ui.select(options, label=label, value=values[fid] or None).bind_value( + values, fid + ).classes("w-full") + elif ftype == "text": + ui.textarea(label, value=values[fid]).bind_value(values, fid).classes("w-full") + else: # string / date / anything else -> single-line input + ui.input(label, value=values[fid]).bind_value(values, fid).classes("w-full") + with ui.row().classes("justify-end w-full").style("gap: 0.5rem;"): + ui.button("Cancel", on_click=lambda _e: _cancel()).props( + 'flat data-testid="input-required-cancel"' + ) + ui.button("Submit", on_click=lambda _e: _submit()).props( + 'color=primary data-testid="input-required-submit"' + ) + return dialog diff --git a/src/exlab_wizard/ui/components/operations_modal.py b/src/exlab_wizard/ui/components/operations_modal.py index dcb4d8a..8e7837a 100644 --- a/src/exlab_wizard/ui/components/operations_modal.py +++ b/src/exlab_wizard/ui/components/operations_modal.py @@ -20,11 +20,13 @@ STATE_RUNNING = "running" STATE_SUSPENDED = "suspended" STATE_COMPLETED = "completed" +STATE_FAILED = "failed" _STATE_GLYPH: dict[str, str] = { STATE_RUNNING: "play_arrow", STATE_SUSPENDED: "pause", STATE_COMPLETED: "check", + STATE_FAILED: "error", } @@ -40,6 +42,40 @@ class OperationRow: run: str plugin: str | None = None + @classmethod + def from_session(cls, session_id: str, session: Any) -> OperationRow: + """Map a controller ``Session`` to a panel row. + + Collapses the §4.7 state machine onto the panel's three buckets: + ``INPUT_REQUIRED`` -> suspended (offers Resume/Cancel), ``DONE`` -> + completed, every other non-terminal state -> running. Shares + ``project_identifier`` with the ``/operations`` route so both label + rows identically (imported lazily to respect the controller/api + import ordering). + """ + from exlab_wizard.controller import SessionState, project_identifier + from exlab_wizard.utils.time import dt_to_iso + + if session.state is SessionState.INPUT_REQUIRED: + row_state = STATE_SUSPENDED + elif session.state is SessionState.DONE: + row_state = STATE_COMPLETED + elif session.state is SessionState.FAILED: + row_state = STATE_FAILED + else: + row_state = STATE_RUNNING + request = session.request + plugin = session.pending_input.get("plugin") if session.pending_input else None + return cls( + operation_id=session_id, + state=row_state, + started_at=dt_to_iso(session.created_at) if session.created_at is not None else "", + equipment=getattr(request, "equipment_id", None) or "", + project=project_identifier(request) or "", + run=getattr(request, "label", None) or "", + plugin=plugin, + ) + def operation_columns() -> list[dict[str, Any]]: """Column definitions for the NiceGUI table (Frontend §9.5).""" @@ -61,7 +97,7 @@ def sort_rows(rows: list[OperationRow]) -> list[OperationRow]: first so the operator clears the longest-pending input first. """ - state_priority = {STATE_SUSPENDED: 0, STATE_RUNNING: 1, STATE_COMPLETED: 2} + state_priority = {STATE_SUSPENDED: 0, STATE_RUNNING: 1, STATE_FAILED: 2, STATE_COMPLETED: 3} return sorted( rows, key=lambda r: (state_priority.get(r.state, 99), r.started_at), diff --git a/src/exlab_wizard/ui/components/session_progress.py b/src/exlab_wizard/ui/components/session_progress.py index 8902e64..3a0f61a 100644 --- a/src/exlab_wizard/ui/components/session_progress.py +++ b/src/exlab_wizard/ui/components/session_progress.py @@ -11,7 +11,7 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any from exlab_wizard.logging import get_logger @@ -19,15 +19,17 @@ _log = get_logger(__name__) -# Phase identifiers from Backend §4.7. The ordered tuple is the canonical -# render order for the progress bar. +# Phase identifiers from Backend §4.7 -- these are the verbatim ``phase`` +# wire-format strings the controller emits (``state_machine.Phase``), so a +# live ``phase`` frame maps onto a row without translation. The ordered +# tuple is the canonical render order for the progress bar. PHASES: tuple[str, ...] = ( "validating_inputs", "rendering_template", "running_plugins", "writing_cache", - "post_validation", - "queueing_sync", + "validating_post_creation", + "queueing_nas_sync", ) PHASE_LABELS: dict[str, str] = { @@ -35,8 +37,8 @@ "rendering_template": "Rendering template", "running_plugins": "Running plugins", "writing_cache": "Writing cache", - "post_validation": "Validating post-creation", - "queueing_sync": "Queueing NAS sync", + "validating_post_creation": "Validating post-creation", + "queueing_nas_sync": "Queueing NAS sync", } @@ -158,3 +160,66 @@ def session_progress( "color=info" ).style("flex-grow: 1;") return column + + +# --------------------------------------------------------------------------- +# Live state (Backend §4.6.2 WS frames -> render args) +# --------------------------------------------------------------------------- + + +@dataclass +class SessionProgressState: + """Mutable phase/sub-progress state folded from controller WS frames. + + The wizard's Confirm & Create step renders :func:`session_progress` + from this object inside a ``@ui.refreshable`` and re-renders it as the + creation pipeline publishes frames over :meth:`CreationController.subscribe`. + """ + + active_phase: str | None = None + completed: list[str] = field(default_factory=list) + plugin_current: int | None = None + plugin_total: int | None = None + plugin_name: str | None = None + + +def apply_frame(state: SessionProgressState, frame: dict[str, Any]) -> bool: + """Fold one controller WS ``frame`` into ``state`` in place. + + Returns ``True`` when the visible progress changed (the caller should + re-render). Recognises ``phase`` and ``progress`` frames plus the + terminal ``done``; ``failed`` and ``input_required`` are left to the + caller (the wizard surfaces those out-of-band). + """ + + def _complete_through(upto: int) -> None: + # Mark the first ``upto`` phases complete (idempotent) -- buffered + # frames may have been coalesced, so a phase becoming active (or the + # session finishing) implies its predecessors finished. + for phase in PHASES[:upto]: + if phase not in state.completed: + state.completed.append(phase) + + kind = frame.get("kind") + if kind == "phase": + phase = frame.get("phase") + if phase not in PHASES: + # ``input_required`` / ``done`` arrive as their own ``kind``; + # any unknown phase string is ignored rather than mis-rendered. + return False + _complete_through(PHASES.index(phase)) + state.active_phase = phase + if phase != "running_plugins": + state.plugin_current = state.plugin_total = state.plugin_name = None + return True + if kind == "progress": + state.active_phase = "running_plugins" + state.plugin_current = frame.get("current") + state.plugin_total = frame.get("total") + state.plugin_name = frame.get("plugin") or frame.get("name") + return True + if kind == "done": + _complete_through(len(PHASES)) + state.active_phase = None + return True + return False diff --git a/src/exlab_wizard/ui/mount.py b/src/exlab_wizard/ui/mount.py index b4869d7..e0a562b 100644 --- a/src/exlab_wizard/ui/mount.py +++ b/src/exlab_wizard/ui/mount.py @@ -196,6 +196,7 @@ def _on_file_context_action(entry: Any, action: str) -> None: 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, @@ -344,6 +345,32 @@ def _on_save(updated: Any) -> None: 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() + def _nas_handlers( equipment_id: str, ) -> tuple[Callable[[str], None], Callable[[], None]]: @@ -366,13 +393,20 @@ async def _on_test_equipment(equipment_id: str) -> Any: nas_password_present_for=lambda equipment_id: nas_password_present(deps, equipment_id), nas_credential_handlers=_nas_handlers, on_test_equipment=_on_test_equipment, + 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) + return problems_page.render_problems_page( + findings=findings, + last_audit_at=getattr(deps, "last_audit_at", None), + ) @ui.page("/staging") def _staging() -> Any: @@ -684,16 +718,23 @@ def _is_setup_ready(deps: Any) -> bool: return False -def _apply_autostart(deps: Any, enabled: bool) -> None: +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 + return None toggle: Callable[[bool], Any] | None = getattr(deps, "autostart_toggle", None) if toggle is None: - return + return None try: - toggle(enabled) + return bool(toggle(enabled)) except Exception as exc: - _log.warning("autostart toggle failed in welcome: %s", exc) + _log.warning("autostart toggle failed: %s", exc) + return None def _build_main_state( @@ -710,6 +751,7 @@ def _build_main_state( # 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) return main_page.MainPageState( setup_incomplete=not _is_setup_ready(deps), setup_next_action=_setup_next_action(deps), @@ -718,9 +760,49 @@ def _build_main_state( selected_node_is_received=is_received, right_pane_collapsed=right_pane_collapsed, folder_feed_path=selected_node, + operations_count=ops_count, + operations_input_required=ops_input_required, + creation_in_flight=ops_active > 0, + # Real Problems counts from the 30 s background audit (T6 / §B5). + problems_count_hard=int(getattr(deps, "last_audit_hard", 0) or 0), + problems_count_soft=int(getattr(deps, "last_audit_soft", 0) or 0), ) +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. @@ -1246,6 +1328,196 @@ def _open_in_os(path: str) -> bool: 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. @@ -1402,6 +1674,9 @@ def _lims_catalogue_projects(deps: Any) -> list[dict[str, Any]]: 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, @@ -1471,6 +1746,59 @@ async def _await_session(controller: Any, handle: Any) -> Any: 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 @@ -1499,7 +1827,9 @@ async def _submit_project(deps: Any, state: Any, ui: Any) -> None: operator=readme.get("operator", ""), objective=readme.get("objective", ""), ) - await _run_creation(controller, controller.create_project, request, ui, label="Project") + 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: @@ -1530,7 +1860,9 @@ async def _submit_run(deps: Any, state: Any, run_kind: RunKind, ui: Any) -> None 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) + await _run_creation( + controller, controller.create_run, request, ui, label=kind_label, wizard_state=state + ) async def _run_creation( @@ -1540,12 +1872,20 @@ async def _run_creation( ui: Any, *, label: str, + wizard_state: Any = None, ) -> None: - """Drive a create_* call to completion and toast the outcome.""" + """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) diff --git a/src/exlab_wizard/ui/pages/main.py b/src/exlab_wizard/ui/pages/main.py index 7adfe75..0082ea0 100644 --- a/src/exlab_wizard/ui/pages/main.py +++ b/src/exlab_wizard/ui/pages/main.py @@ -49,6 +49,14 @@ class MainPageState: active_tab: str = "details" # "details" | "problems" problems_count_hard: int = 0 problems_count_soft: int = 0 + # In-flight controller operations (Frontend §9.5). ``operations_count`` + # gates the toolbar [Operations…] button; ``operations_input_required`` + # drives the footer Sync segment's "N need input" warning. + operations_count: int = 0 + operations_input_required: int = 0 + # §9.6 single-equipment concurrency: the three creation buttons are + # disabled while any session is mid-flight (non-terminal). + creation_in_flight: bool = False # Legacy field — the orchestrator pipeline is always active under # Redesign §3.1, so this always renders True in production. orchestrator_enabled: bool = True @@ -133,6 +141,7 @@ def render_file_explorer_page( on_open_settings: Callable[[], None], on_refresh: Callable[[], None], on_select_node: Callable[[str], None], + on_open_operations: Callable[[], None] | None = None, 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, @@ -195,12 +204,25 @@ def render_file_explorer_page( ntr_btn = ui.button("New Test Run", on_click=lambda _evt: on_open_new_test_run()).props( 'color=warning data-testid="toolbar-new-test-run"' ) - if s.selected_node_is_received: + # Creation is disabled on received-equipment nodes (decision 1) and + # while any session is mid-flight (single-equipment concurrency, §9.6). + if s.selected_node_is_received or s.creation_in_flight: for btn in (np_btn, nr_btn, ntr_btn): btn.props("disable") + if s.creation_in_flight and not s.selected_node_is_received: + np_btn.tooltip("A creation is already in progress") ui.button("Add Equipment", on_click=lambda _evt: on_open_add_equipment()).props( 'color=primary data-testid="toolbar-add-equipment"' ) + # [Operations…] surfaces only while ≥1 operation is in flight + # (Frontend §9.5). Label carries the count; a warning color flags + # any suspended (INPUT_REQUIRED) session needing an answer. + if on_open_operations is not None and s.operations_count > 0: + ops_color = "warning" if s.operations_input_required > 0 else "primary" + ui.button( + f"Operations ({s.operations_count})", + on_click=lambda _evt: on_open_operations(), + ).props(f'flat color={ops_color} data-testid="toolbar-operations"') ui.button("Refresh", on_click=lambda _evt: on_refresh()).props( 'flat data-testid="toolbar-refresh"' ) @@ -265,8 +287,11 @@ def _route_run_context(node_id: str, action: str) -> None: # tab landed at a different Y per state -- that vertical shift was # the up/down "jump". Pinning to the top ties the tab's Y to the # constant panel top, so it holds its line on toggle. - with outer_split.after, ui.element("div").classes("w-full h-full").style( - "display: flex; flex-direction: row; flex-wrap: nowrap; align-items: stretch;" + with ( + outer_split.after, + ui.element("div") + .classes("w-full h-full") + .style("display: flex; flex-direction: row; flex-wrap: nowrap; align-items: stretch;"), ): with ui.element("div").style( "flex: 1 1 auto; min-width: 0; height: 100%; overflow: auto;" @@ -308,14 +333,17 @@ def _route_run_context(node_id: str, action: str) -> None: "color: var(--color-muted, #8892a4);" ) ) - with toggle, ui.column().style( - "align-items: center; gap: 6px; flex-wrap: nowrap; " - "height: 100%; width: 100%; padding: 8px 2px; " - # Surface fill + border + soft shadow so the chevron and - # label read as a distinct raised tab against the page. - "background: var(--color-surface, #ffffff); " - "border: 1px solid var(--color-border, #dde3ed); border-radius: 6px; " - "box-shadow: 0 1px 3px rgba(0, 54, 96, 0.12);" + with ( + toggle, + ui.column().style( + "align-items: center; gap: 6px; flex-wrap: nowrap; " + "height: 100%; width: 100%; padding: 8px 2px; " + # Surface fill + border + soft shadow so the chevron and + # label read as a distinct raised tab against the page. + "background: var(--color-surface, #ffffff); " + "border: 1px solid var(--color-border, #dde3ed); border-radius: 6px; " + "box-shadow: 0 1px 3px rgba(0, 54, 96, 0.12);" + ), ): ui.label(chevron).style( "flex: 0 0 auto; font-size: 12px; line-height: 1; " @@ -353,10 +381,23 @@ def _route_run_context(node_id: str, action: str) -> None: ), ui.row().classes("items-center w-full"), ): - status_bar_segment.status_bar_segment( - label="Sync", - state=status_bar_segment.SEGMENT_NORMAL, - ) + # Sync segment doubles as the Operations entry point: when any + # session is suspended awaiting input it flips to a warning + # "N operations need input" and opens the same modal (§3.5.5). + if s.operations_input_required > 0: + status_bar_segment.status_bar_segment( + label=f"{s.operations_input_required} operations need input", + state=status_bar_segment.SEGMENT_WARNING, + on_click=on_open_operations, + ) + else: + # Only clickable when there is something to show, so a click + # never opens an empty Operations panel. + status_bar_segment.status_bar_segment( + label="Sync", + state=status_bar_segment.SEGMENT_NORMAL, + on_click=on_open_operations if s.operations_count > 0 else None, + ) status_bar_segment.status_bar_segment( label="Validator", state=status_bar_segment.SEGMENT_NORMAL, @@ -443,8 +484,10 @@ def _render_right_pane( on_run_staging_action=on_run_staging_action, ) with ui.tab_panel("problems"): + total = state.problems_count_hard + state.problems_count_soft ui.label( - f"Showing 0 of {state.problems_count_hard + state.problems_count_soft} findings", + f"{total} findings ({state.problems_count_hard} hard, " + f"{state.problems_count_soft} soft)", ).props('data-testid="problems-summary"').style( "font-family: var(--font-mono); color: var(--color-muted);" ) diff --git a/src/exlab_wizard/ui/pages/problems.py b/src/exlab_wizard/ui/pages/problems.py index 3463f37..d157945 100644 --- a/src/exlab_wizard/ui/pages/problems.py +++ b/src/exlab_wizard/ui/pages/problems.py @@ -169,8 +169,16 @@ def render_problems_page( state: ProblemsPageState | None = None, on_override: Callable[[str], None] | None = None, on_revoke_override: Callable[[str], None] | None = None, + last_audit_at: str | None = None, + refresh_interval_seconds: int = 30, ) -> Any: - """Render the Problems tab content.""" + """Render the Problems tab content. + + ``last_audit_at`` is the ISO timestamp of the most recent background + audit pass (``deps.last_audit_at``); the footer shows it as ``HH:MM:SS`` + plus a live "Next refresh in Ns" countdown over ``refresh_interval_seconds`` + (T6 / §B5). + """ s = state or ProblemsPageState() visible = filter_findings(findings, s) @@ -276,12 +284,47 @@ def render_problems_page( on_click=lambda _evt, fid=finding.finding_id: on_revoke_override(fid), ).props(f'flat data-testid="problems-row-{idx}-revoke"') - ui.label( - f"Showing {len(visible)} of {len(findings)} findings · Last audit: --", - ).style( - "font-family: var(--font-mono); " - "font-size: var(--text-xs); " - "color: var(--color-muted); " - "padding: 0.5rem 0;" + from exlab_wizard.utils.time import parse_utc_iso, utc_now + + def _audit_time_text() -> str: + if not last_audit_at: + return "--" + try: + return parse_utc_iso(last_audit_at).strftime("%H:%M:%S") + except Exception: + return last_audit_at + + def _remaining_seconds() -> int: + if not last_audit_at: + return refresh_interval_seconds + try: + elapsed = (utc_now() - parse_utc_iso(last_audit_at)).total_seconds() + except Exception: + return refresh_interval_seconds + # Clamp at 0 once the interval has elapsed (an overdue / in-progress + # pass) rather than re-cycling, which would misreport the wait. + return max(0, int(refresh_interval_seconds - elapsed)) + + footer = ( + ui.label("") + .props('data-testid="problems-footer"') + .style( + "font-family: var(--font-mono); " + "font-size: var(--text-xs); " + "color: var(--color-muted); " + "padding: 0.5rem 0;" + ) ) + + def _render_footer() -> None: + footer.text = ( + f"Showing {len(visible)} of {len(findings)} findings · " + f"Last audit: {_audit_time_text()} · " + f"Next refresh in {_remaining_seconds()}s" + ) + + _render_footer() + # Tick the countdown once a second; the background audit refreshes + # ``last_audit_at`` itself on its own 30 s cadence (§4.6.2). + ui.timer(1.0, _render_footer) return container diff --git a/src/exlab_wizard/ui/pages/settings.py b/src/exlab_wizard/ui/pages/settings.py index 597450e..0f115bb 100644 --- a/src/exlab_wizard/ui/pages/settings.py +++ b/src/exlab_wizard/ui/pages/settings.py @@ -1,7 +1,6 @@ """Settings dialog (Frontend Spec §7). -Two-pane modal with a left vertical-nav and a right content area. Eight -sections (``operators`` is deferred pending the chip editor); +Two-pane modal with a left vertical-nav and a right content area; setup-incomplete mode auto-selects the first incomplete one. """ @@ -27,9 +26,10 @@ "lims", "equipment", "nas_cleanup", - # "operators" is deferred -- backend OperatorsConfig + the - # controller/creation.py allowlist gate stay wired and are no-ops while - # the allowlist defaults to []. The chip editor lands in a future update. + # "operators" backs OperatorsConfig.allowlist (Frontend §7.9). It is a + # chip editor and is non-gating: the allowlist defaults to [] (any + # operator allowed) and it is never added to ``_missing_setup_sections``. + "operators", "validator", "logging", "orchestrator", @@ -49,6 +49,7 @@ "equipment": "Equipment List", NAS_CREDENTIALS_SECTION: "NAS Credentials", "nas_cleanup": "NAS Cleanup", + "operators": "Operators", "validator": "Validator", "logging": "Logging", "orchestrator": "Orchestrator Mode", @@ -202,6 +203,10 @@ def render_settings_page( nas_credential_handlers: Callable[[str], tuple[Callable[[str], None], Callable[[], None]]] | None = None, on_test_equipment: Callable[[str], Any] | None = None, + autostart_registered: bool = False, + on_set_autostart: Callable[[bool], bool | None] | None = None, + on_quit: Callable[[], None] | None = None, + tray_available: bool = False, ) -> Any: """Render the settings dialog. @@ -344,6 +349,10 @@ def _select_section(section: str) -> None: nas_password_present_for=nas_password_present_for, nas_credential_handlers=nas_credential_handlers, on_test_equipment=on_test_equipment, + autostart_registered=autostart_registered, + on_set_autostart=on_set_autostart, + on_quit=on_quit, + tray_available=tray_available, ) section_bodies[section] = body @@ -379,6 +388,91 @@ def _do_save(_evt: Any = None) -> None: return card +def _render_chip_editor( + values: list[str], + *, + add_label: str, + testid: str, + validate: Callable[[str], str | None] | None = None, + on_reset: Callable[[], None] | None = None, + reset_label: str = "Reset to defaults", + empty_text: str = "(none)", +) -> None: + """Reusable chip / list editor bound to a draft string list (T7 / T10). + + Mutates ``values`` in place -- ``[+ Add]`` appends (rejecting blanks, + duplicates, and ``validate`` failures), each chip carries a delete, and + an optional ``[Reset]`` replaces the contents -- so persistence rides + the existing draft -> ``finalize_settings_draft`` -> Save path with no + new plumbing. Entries are stored verbatim (case-sensitive, no + lowercasing); whitespace is trimmed on add. + """ + from nicegui import ui + + chips = ui.row().classes("items-center w-full").style("gap: 0.35rem; flex-wrap: wrap;") + + def _render_chips() -> None: + chips.clear() + with chips: + if not values: + ui.label(empty_text).props(f'data-testid="{testid}-empty"').style( + "color: var(--color-muted);" + ) + for idx, value in enumerate(values): + with ( + ui.row() + .classes("items-center") + .props(f'data-testid="{testid}-chip"') + .style( + "gap: 0.15rem; background: var(--color-rule); " + "border-radius: var(--radius-sm); padding: 0.05rem 0.1rem 0.05rem 0.5rem;" + ) + ): + ui.label(value).style( + "font-family: var(--font-mono); font-size: var(--text-xs);" + ) + ui.button(icon="close", on_click=lambda _e, i=idx: _remove(i)).props( + "flat dense round size=sm" + ) + + def _remove(idx: int) -> None: + if 0 <= idx < len(values): + del values[idx] + _render_chips() + + _render_chips() + + new_input = ui.input(label=add_label).props(f'data-testid="{testid}-input"') + + def _add() -> None: + raw = (new_input.value or "").strip() + if not raw: + return + if validate is not None: + error = validate(raw) + if error is not None: + notifications.notify_error(error) + return + if raw in values: + notifications.notify_error(f"{raw!r} is already in the list") + return + values.append(raw) + _render_chips() + new_input.value = "" + + with ui.row().classes("items-center").style("gap: 0.5rem;"): + ui.button("+ Add", on_click=lambda _e: _add()).props(f'flat data-testid="{testid}-add"') + if on_reset is not None: + + def _reset() -> None: + on_reset() + _render_chips() + + ui.button(reset_label, on_click=lambda _e: _reset()).props( + f'flat data-testid="{testid}-reset"' + ) + + def _render_section_body( section: str, draft: Config, @@ -390,6 +484,10 @@ def _render_section_body( nas_credential_handlers: Callable[[str], tuple[Callable[[str], None], Callable[[], None]]] | None = None, on_test_equipment: Callable[[str], Any] | None = None, + autostart_registered: bool = False, + on_set_autostart: Callable[[bool], bool | None] | None = None, + on_quit: Callable[[], None] | None = None, + tray_available: bool = False, ) -> None: """Render the content for a single section, bound to ``draft``. @@ -474,13 +572,39 @@ def _render_section_body( ui.checkbox( "Retain .exlab-wizard/ metadata", value=draft.nas_cleanup.retain_cache ).bind_value(draft.nas_cleanup, "retain_cache") + elif section == "operators": + # Frontend §7.9: empty allowlist = any operator; non-empty = the + # wizard renders a dropdown of these names and rejects free-text. + # Case-sensitive (OperatorsConfig is str_strip_whitespace, not + # lowercased) and non-gating. + ui.label( + "If empty, the operator field accepts any value. If non-empty, the wizard " + "shows a dropdown of these names and rejects free-text." + ).style("color: var(--color-muted); font-size: var(--text-sm);") + _render_chip_editor( + draft.operators.allowlist, + add_label="Add operator username", + testid="settings-operators", + empty_text="Any operator allowed (allowlist empty)", + ) elif section == "validator": ui.number( label="Max content-scan size (MiB)", value=draft.validator.content_scan_max_mib, ).bind_value(draft.validator, "content_scan_max_mib") - ui.label( - "Scanned file extensions: " + ", ".join(draft.validator.content_scan_extensions) + ui.label("Scanned file extensions").style("color: var(--color-body);") + + def _reset_extensions() -> None: + from exlab_wizard.config.models import _default_content_scan_extensions + + draft.validator.content_scan_extensions[:] = _default_content_scan_extensions() + + _render_chip_editor( + draft.validator.content_scan_extensions, + add_label="Add extension (e.g. .txt)", + testid="settings-scan-ext", + validate=lambda v: None if v.startswith(".") else "Extensions must start with '.'", + on_reset=_reset_extensions, ) elif section == "logging": ui.radio(["DEBUG", "INFO", "WARN", "ERROR"], value=draft.logging.level).bind_value( @@ -508,12 +632,74 @@ def _render_section_body( placeholder=str(suggested_staging_root()), ).bind_value(draft.orchestrator, "staging_root") elif section == "application": - # "Start at login" is the autostart toggle, not a config.yaml - # field -- it is set from the welcome card. Shown here for - # discoverability; wiring it is a follow-up. - ui.checkbox("Start ExLab-Wizard at login") - ui.label("Show in system tray: available") - ui.button("Quit ExLab-Wizard now").props("flat") + # "Start at login" (T8): applied immediately (NOT draft-bound, + # §7.13). Seeded from the real registration state; on toggle it + # reflects the actual post-op ``is_registered()`` and reverts on + # failure. Disabled when no toggle is wired (headless/tests). + _guard = {"busy": False} + autostart_box: Any = None + + def _on_autostart(event: Any) -> None: + if _guard["busy"] or on_set_autostart is None: + return + actual = on_set_autostart(bool(event.value)) + if actual is not None and bool(actual) != bool(event.value): + # Programmatic revert re-fires on_change synchronously; + # the guard makes that re-entrant call a no-op. + _guard["busy"] = True + try: + autostart_box.value = bool(actual) + finally: + _guard["busy"] = False + + autostart_box = ui.checkbox( + "Start ExLab-Wizard at login", + value=autostart_registered, + on_change=_on_autostart, + ).props('data-testid="settings-autostart"') + if on_set_autostart is None: + autostart_box.props("disable") + + # Real tray availability + window-on-close behavior (T11, §7.13). + tray_text = "available" if tray_available else "unavailable (window-only)" + ui.label(f"Show in system tray: {tray_text}").props( + 'data-testid="settings-tray-status"' + ) + ui.label( + "Closing the window keeps ExLab-Wizard running in the tray; " + "use Quit to exit completely." + ).style("color: var(--color-muted); font-size: var(--text-sm);") + + # "Quit ExLab-Wizard now" (T9): graceful shutdown behind a confirm, + # scheduled non-blocking by the host. Disabled when no hook wired. + quit_btn = ui.button("Quit ExLab-Wizard now").props('flat data-testid="settings-quit"') + if on_quit is None: + quit_btn.props("disable") + else: + + def _confirm_quit() -> None: + confirm = ui.dialog() + with ( + confirm, + ui.card().props('data-testid="settings-quit-dialog"'), + ): + ui.label("Quit ExLab-Wizard?").style("font-weight: 600;") + ui.label("In-flight operations are allowed to finish first.").style( + "color: var(--color-muted);" + ) + + def _do_quit() -> None: + confirm.close() + on_quit() + + with ui.row().classes("justify-end w-full").style("gap: 0.5rem;"): + ui.button("Cancel", on_click=lambda _e: confirm.close()).props("flat") + ui.button("Quit", on_click=lambda _e: _do_quit()).props( + 'color=negative data-testid="settings-quit-confirm"' + ) + confirm.open() + + quit_btn.on("click", lambda _e: _confirm_quit()) # Redesign §6: the canonical equipment-config assembler now lives in diff --git a/src/exlab_wizard/ui/pages/wizard_project.py b/src/exlab_wizard/ui/pages/wizard_project.py index e52abec..b5479c2 100644 --- a/src/exlab_wizard/ui/pages/wizard_project.py +++ b/src/exlab_wizard/ui/pages/wizard_project.py @@ -72,6 +72,13 @@ class ProjectWizardState: validator_findings: list[dict[str, Any]] = field(default_factory=list) free_disk_bytes: int | None = None plugin_host_ok: bool = True + # Live creation-progress state, folded from the controller WS stream + # while the Confirm & Create step is showing (T2 / Frontend §10.1). + progress: session_progress.SessionProgressState = field( + default_factory=session_progress.SessionProgressState + ) + # Bound to the confirm step's ``@ui.refreshable`` view's ``.refresh``. + progress_refresh: Callable[..., Any] | None = None def can_advance(state: ProjectWizardState) -> bool: @@ -221,9 +228,22 @@ def _variables_panel() -> None: on_template_change=_variables_panel.refresh, ) if step_id == "confirm": - session_progress.session_progress( - active_phase=None, - ) + + @ui.refreshable + def _progress_view() -> None: + p = s.progress + session_progress.session_progress( + active_phase=p.active_phase, + completed=p.completed, + plugin_current=p.plugin_current, + plugin_total=p.plugin_total, + plugin_name=p.plugin_name, + ) + + _progress_view() + # The submit flow folds WS frames into ``s.progress`` + # and calls this to advance the phase bar live (T2). + s.progress_refresh = _progress_view.refresh with ui.stepper_navigation(): # The first step has nowhere to step back to, so # Cancel is its only exit -- rendering a dead Back diff --git a/src/exlab_wizard/ui/pages/wizard_run.py b/src/exlab_wizard/ui/pages/wizard_run.py index cb0072b..eddcf6a 100644 --- a/src/exlab_wizard/ui/pages/wizard_run.py +++ b/src/exlab_wizard/ui/pages/wizard_run.py @@ -63,6 +63,13 @@ class RunWizardState: template_variables: dict[str, Any] = field(default_factory=dict) readme_fields: dict[str, str] = field(default_factory=dict) validator_findings: list[dict[str, Any]] = field(default_factory=list) + # Live creation-progress state, folded from the controller WS stream + # while the Confirm & Create step is showing (T2 / Frontend §10.1). + progress: session_progress.SessionProgressState = field( + default_factory=session_progress.SessionProgressState + ) + # Bound to the confirm step's ``@ui.refreshable`` view's ``.refresh``. + progress_refresh: Callable[..., Any] | None = None def title_text(state: RunWizardState) -> str: @@ -226,7 +233,22 @@ def _variables_panel() -> None: on_template_change=_variables_panel.refresh, ) if step_id == "confirm": - session_progress.session_progress(active_phase=None) + + @ui.refreshable + def _progress_view() -> None: + p = state.progress + session_progress.session_progress( + active_phase=p.active_phase, + completed=p.completed, + plugin_current=p.plugin_current, + plugin_total=p.plugin_total, + plugin_name=p.plugin_name, + ) + + _progress_view() + # The submit flow folds WS frames into ``state.progress`` + # and calls this to advance the phase bar live (T2). + state.progress_refresh = _progress_view.refresh with ui.stepper_navigation(): # The first step has nowhere to step back to, so # Cancel is its only exit -- rendering a dead Back diff --git a/tests/integration/controller/test_creation_flow.py b/tests/integration/controller/test_creation_flow.py index fc6fb1a..473861f 100644 --- a/tests/integration/controller/test_creation_flow.py +++ b/tests/integration/controller/test_creation_flow.py @@ -157,6 +157,7 @@ def _build_controller( *, plugin_host: PluginHost | None = None, nas_sync: Any = None, + readme_generator: Any = None, ) -> CreationController: return CreationController( config=config, @@ -165,7 +166,9 @@ def _build_controller( plugin_host=plugin_host, cache_creation=CreationWriter(), cache_equipment=EquipmentCacheWriter(), - readme_generator=NoOpReadmeGenerator(), + readme_generator=readme_generator + if readme_generator is not None + else NoOpReadmeGenerator(), nas_sync=nas_sync if nas_sync is not None else NoOpNASSync(), session_store=SessionStore(), ) @@ -657,30 +660,90 @@ async def test_cancel_unknown_session_is_noop(tmp_path: Path) -> None: async def test_noop_readme_generator_writes_minimal_readme(tmp_path: Path) -> None: - gen = NoOpReadmeGenerator() - from exlab_wizard.template.copier_driver import ResolvedTemplate + from datetime import UTC, datetime - resolved = ResolvedTemplate( - name="dummy", - path=tmp_path, - exlab_type="project", - exlab_version="1.0", - ) + from exlab_wizard.constants import CreationLevel + from exlab_wizard.readme import CoreFields, SystemFields + + gen = NoOpReadmeGenerator() ctx = ReadmeContext( - label="My Project", - operator="asmith", - objective="purpose", - equipment_id="EQ1", - project_short_id="PROJ-0001", - run_kind="project", - variables={}, - template=resolved, + level=CreationLevel.PROJECT, + core=CoreFields(label="My Project", operator="asmith", objective="purpose"), + template_fields={}, + config_fields={}, + custom_fields=[], + system=SystemFields( + created=datetime(2026, 1, 1, tzinfo=UTC), + created_by="osuser", + equipment={"id": "EQ1", "label": "Eq One"}, + template={"name": "dummy", "version": "1.0"}, + project="My Project", + run=None, + run_kind="", + ), ) - out = await gen.generate(tmp_path, ctx) - assert out.is_file() - content = out.read_text(encoding="utf-8") + readme, cache = await gen.generate(tmp_path, ctx) + assert readme.is_file() + content = readme.read_text(encoding="utf-8") assert "# My Project" in content assert "purpose" in content + # NoOp reports the cache path but does not write the §10 cache file. + assert cache.name == "readme_fields.json" + + +async def test_real_readme_generator_writes_frontmatter_and_cache(tmp_path: Path) -> None: + """T1 (§C1): the production ``ReadmeGenerator`` -- injected by + ``tray.dependencies`` -- produces a §10-compliant ``README.md`` (YAML + front matter spanning all four field layers) plus the + ``readme_fields.json`` cache, instead of the NoOp stub.""" + import yaml + + from exlab_wizard.api.schemas import ReadmeFieldsJson + from exlab_wizard.config.models import READMEDefaultField + from exlab_wizard.constants import ( + README_FIELDS_JSON_NAME, + README_FILE_NAME, + FieldType, + ) + from exlab_wizard.readme import ReadmeGenerator + + local_root = tmp_path / "data" + local_root.mkdir() + config = _build_config(local_root) + # A lab-policy (config-layer) field layered on top of the core set. + config.readme.defaults.append( + READMEDefaultField(id="irb_protocol", label="IRB protocol", type=FieldType.STRING) + ) + controller = _build_controller(config, readme_generator=ReadmeGenerator()) + + # readme_extra carries a config-layer value and a user-added custom field. + request = _project_request( + readme_extra={"irb_protocol": "IRB-2026-0042", "Collaborator": "Dr. J. Lee"} + ) + handle = await controller.create_project(request) + final = await _drain_to_done(controller, handle.session_id) + assert final["state"] is SessionState.DONE + + project_dir = local_root / "EQ1" / "Cortex Q3 Pilot" + readme = project_dir / README_FILE_NAME + assert readme.is_file() + text = readme.read_text(encoding="utf-8") + assert text.startswith("---\n") + front_matter = yaml.safe_load(text.split("---\n")[1]) + assert front_matter["core_fields"]["label"] == "Cortex Q3 calibration" + assert front_matter["config_fields"]["irb_protocol"] == "IRB-2026-0042" + assert {"label": "Collaborator", "value": "Dr. J. Lee"} in front_matter["custom_fields"] + # §10.6 system block: created_by is the OS user (non-empty); project is + # the machine-safe LIMS short id (§3.1), NOT the folder name; and run is + # null for a project-level README. + assert front_matter["system_fields"]["created_by"] + assert front_matter["system_fields"]["project"] == "PROJ-0042" + assert front_matter["system_fields"]["run"] is None + + cache = project_dir / CACHE_DIR_NAME / README_FIELDS_JSON_NAME + assert cache.is_file() + decoded = msgspec.json.decode(cache.read_bytes(), type=ReadmeFieldsJson) + assert decoded.config_fields["irb_protocol"] == "IRB-2026-0042" async def test_noop_nas_sync_returns_none(tmp_path: Path) -> None: diff --git a/tests/unit/controller/test_session_store.py b/tests/unit/controller/test_session_store.py index aa3093a..5cd138a 100644 --- a/tests/unit/controller/test_session_store.py +++ b/tests/unit/controller/test_session_store.py @@ -21,7 +21,11 @@ import pytest -from exlab_wizard.controller.session_store import Session, SessionStore +from exlab_wizard.controller.session_store import ( + Session, + SessionStore, + project_identifier, +) from exlab_wizard.controller.state_machine import Phase, SessionState # --------------------------------------------------------------------------- @@ -80,6 +84,50 @@ def test_get_returns_session_after_open() -> None: assert fetched is session +def test_iter_sorted_returns_pairs_oldest_created_first() -> None: + store = SessionStore() + first = store.open("project", {}) + second = store.open("run", {}) + # Force a deterministic created_at ordering. + store.get(first.session_id).created_at = datetime(2026, 1, 1, tzinfo=UTC) + store.get(second.session_id).created_at = datetime(2026, 1, 2, tzinfo=UTC) + ordered = store.iter_sorted() + assert [sid for sid, _ in ordered] == [first.session_id, second.session_id] + + +def test_gc_publishes_terminal_frame_to_live_subscriber() -> None: + """An abandoned-session GC pass must wake a live ``subscribe()`` consumer. + + The GC closes the session directly on the store (bypassing the + controller's ``_publish``), so it must itself push a terminal frame or + the in-process wizard progress loop parks forever on ``queue.get()``. + """ + store = SessionStore() + session = store.open("project", {}) + live = store.get(session.session_id) + live.state = SessionState.INPUT_REQUIRED + live.last_heartbeat = datetime(2000, 1, 1, tzinfo=UTC) # long abandoned + queue: asyncio.Queue[dict] = asyncio.Queue() + live.event_queue = queue + + store._gc_once(timedelta(seconds=1)) + + frame = queue.get_nowait() + assert frame["kind"] == "failed" + assert frame["error"]["code"] == "session_abandoned" + + +def test_project_identifier_prefers_short_id_then_falls_back_to_project_name() -> None: + from types import SimpleNamespace + + proj = SimpleNamespace(project_short_id=None, lims_project={"short_id": "PROJ-0042"}) + assert project_identifier(proj) == "PROJ-0042" + run = SimpleNamespace(project_short_id=None, lims_project={}, project_name="Cortex Q3 Pilot") + assert project_identifier(run) == "Cortex Q3 Pilot" + empty = SimpleNamespace(project_short_id=None, lims_project={}) + assert project_identifier(empty) is None + + # --------------------------------------------------------------------------- # transition # --------------------------------------------------------------------------- diff --git a/tests/unit/lims/test_catalogue.py b/tests/unit/lims/test_catalogue.py index fbbc300..fc579c7 100644 --- a/tests/unit/lims/test_catalogue.py +++ b/tests/unit/lims/test_catalogue.py @@ -64,7 +64,9 @@ def test_read_non_object_raises(tmp_path) -> None: read_catalogue(path, expected_endpoint="http://x") -def test_schema_version_mismatch_raises(tmp_path) -> None: +def test_schema_version_mismatch_treated_as_absent(tmp_path, caplog) -> None: + # §7.2.9.3: a schema_version mismatch is "catalogue absent" -> read + # returns None + WARN (the consumer falls through), not a hard error. path = tmp_path / "cat.json" payload = { "schema_version": "9.9", @@ -74,8 +76,10 @@ def test_schema_version_mismatch_raises(tmp_path) -> None: "projects": [], } path.write_bytes(msgspec.json.encode(payload)) - with pytest.raises(ConfigError, match="schema_version"): - read_catalogue(path, expected_endpoint="http://lims.test/api/v1") + with caplog.at_level("WARNING"): + result = read_catalogue(path, expected_endpoint="http://lims.test/api/v1") + assert result is None + assert any("schema_version" in r.message for r in caplog.records) def test_endpoint_mismatch_raises(tmp_path) -> None: diff --git a/tests/unit/ui/test_components.py b/tests/unit/ui/test_components.py index 5fd87fc..e1a75f9 100644 --- a/tests/unit/ui/test_components.py +++ b/tests/unit/ui/test_components.py @@ -184,16 +184,50 @@ def test_session_progress_phase_order_is_canonical() -> None: """The phase enum order matches Frontend §10.1.""" rows = session_progress.compute_phase_rows(active_phase=None) + # Verbatim controller wire-format phase strings (state_machine.Phase), + # so a live ``phase`` frame maps onto a row without translation. assert [r.phase for r in rows] == [ "validating_inputs", "rendering_template", "running_plugins", "writing_cache", - "post_validation", - "queueing_sync", + "validating_post_creation", + "queueing_nas_sync", ] +def test_apply_frame_advances_phase_and_marks_predecessors_done() -> None: + state = session_progress.SessionProgressState() + assert session_progress.apply_frame(state, {"kind": "phase", "phase": "running_plugins"}) + assert state.active_phase == "running_plugins" + assert "validating_inputs" in state.completed + assert "rendering_template" in state.completed + + +def test_apply_frame_progress_sets_plugin_sub_row() -> None: + state = session_progress.SessionProgressState() + changed = session_progress.apply_frame( + state, {"kind": "progress", "current": 1, "total": 3, "plugin": "demo"} + ) + assert changed + assert state.active_phase == "running_plugins" + assert (state.plugin_current, state.plugin_total, state.plugin_name) == (1, 3, "demo") + + +def test_apply_frame_done_completes_all_phases() -> None: + state = session_progress.SessionProgressState(active_phase="writing_cache") + assert session_progress.apply_frame(state, {"kind": "done", "result": {}}) + assert set(state.completed) == set(session_progress.PHASES) + assert state.active_phase is None + + +def test_apply_frame_ignores_unknown_kinds_and_phases() -> None: + state = session_progress.SessionProgressState() + assert not session_progress.apply_frame(state, {"kind": "input_required"}) + assert not session_progress.apply_frame(state, {"kind": "phase", "phase": "bogus"}) + assert state.active_phase is None + + def test_session_progress_active_phase_marked() -> None: """The active phase has fraction 0.5 and ``is_active``.""" @@ -504,6 +538,89 @@ def test_operations_modal_state_glyph_known_states() -> None: assert operations_modal.state_glyph("completed") == "check" +def test_operation_row_from_session_maps_state_buckets_and_fields() -> None: + from datetime import UTC, datetime + from types import SimpleNamespace + + from exlab_wizard.controller import SessionState + + request = SimpleNamespace( + equipment_id="EQ1", + label="My Run", + lims_project={"short_id": "PROJ-0042"}, + project_short_id=None, + ) + suspended = SimpleNamespace( + state=SessionState.INPUT_REQUIRED, + created_at=datetime(2026, 1, 1, tzinfo=UTC), + request=request, + pending_input={"plugin": "demo", "reason": "need value"}, + ) + row = operations_modal.OperationRow.from_session("s1", suspended) + assert row.state == operations_modal.STATE_SUSPENDED + assert (row.operation_id, row.equipment, row.project, row.run) == ("s1", "EQ1", "PROJ-0042", "My Run") + assert row.plugin == "demo" + + running = SimpleNamespace( + state=SessionState.RENDERING, created_at=None, request=request, pending_input=None + ) + r2 = operations_modal.OperationRow.from_session("s2", running) + assert r2.state == operations_modal.STATE_RUNNING + assert r2.started_at == "" # created_at None -> empty + assert r2.plugin is None + + done = SimpleNamespace( + state=SessionState.DONE, created_at=None, request=request, pending_input=None + ) + assert operations_modal.OperationRow.from_session("s3", done).state == ( + operations_modal.STATE_COMPLETED + ) + + failed = SimpleNamespace( + state=SessionState.FAILED, created_at=None, request=request, pending_input=None + ) + # A failed op stays in the panel but is labelled distinctly (not "running"). + assert operations_modal.OperationRow.from_session("s4", failed).state == ( + operations_modal.STATE_FAILED + ) + + +# --------------------------------------------------------------------------- +# input_required_dialog (T5) +# --------------------------------------------------------------------------- + + +def test_collect_default_values_seeds_from_declared_defaults() -> None: + from exlab_wizard.ui.components.input_required_dialog import collect_default_values + + fields = [ + {"id": "sample", "type": "string", "default": "tissue"}, + {"id": "qc_passed", "type": "boolean"}, + {"id": "notes", "type": "text"}, + {"key": "legacy", "type": "string"}, # id falls back to key + {"type": "string"}, # no id/key -> skipped + ] + values = collect_default_values(fields) + assert values == {"sample": "tissue", "qc_passed": False, "notes": "", "legacy": ""} + + +def test_input_required_dialog_builds_without_raising() -> None: + from exlab_wizard.ui.components.input_required_dialog import input_required_dialog + + out = input_required_dialog( + plugin="demo", + reason="need a value", + fields=[ + {"id": "x", "type": "string", "default": "d"}, + {"id": "mode", "type": "choice", "options": ["a", "b"]}, + {"id": "ok", "type": "boolean"}, + ], + on_submit=lambda _v: None, + on_cancel=lambda: None, + ) + assert out is not None + + # --------------------------------------------------------------------------- # bandwidth_schedule_editor # --------------------------------------------------------------------------- diff --git a/tests/unit/ui/test_mount.py b/tests/unit/ui/test_mount.py index f813985..dfba81a 100644 --- a/tests/unit/ui/test_mount.py +++ b/tests/unit/ui/test_mount.py @@ -252,6 +252,14 @@ def test_build_main_state_marks_incomplete_without_config() -> None: assert state.orchestrator_enabled is True +def test_build_main_state_sources_problems_counts_from_audit() -> None: + # Counts come straight off deps (the 30 s background audit), not a + # per-render re-audit (T6 / §B5). + state = mount._build_main_state(_deps(last_audit_hard=3, last_audit_soft=12)) + assert state.problems_count_hard == 3 + assert state.problems_count_soft == 12 + + def test_build_main_state_always_on_orchestrator() -> None: """Redesign §3.1: the orchestrator pipeline is unconditional.""" deps = _deps( @@ -265,6 +273,36 @@ def test_build_main_state_always_on_orchestrator() -> None: assert state.orchestrator_enabled is True +# --------------------------------------------------------------------------- +# _operation_counts (T3/T4) +# --------------------------------------------------------------------------- + + +def test_operation_counts_distinguishes_panel_active_and_input_required() -> None: + from exlab_wizard.controller.session_store import SessionStore + + store = SessionStore() + running = store.open("project", {}) + suspended = store.open("run", {}) + failed = store.open("project", {}) + done = store.open("run", {}) + store.get(running.session_id).state = SessionState.RENDERING + store.get(suspended.session_id).state = SessionState.INPUT_REQUIRED + store.get(failed.session_id).state = SessionState.FAILED + store.get(done.session_id).state = SessionState.DONE + + deps = SimpleNamespace(controller=SimpleNamespace(session_store=store)) + panel, input_required, active = mount._operation_counts(deps) + # panel: all but DONE/ABORTED -> running + suspended + failed + assert panel == 3 + assert input_required == 1 # only the suspended session + assert active == 2 # strictly non-terminal -> running + suspended (FAILED excluded) + + +def test_operation_counts_zero_without_controller() -> None: + assert mount._operation_counts(SimpleNamespace()) == (0, 0, 0) + + # --------------------------------------------------------------------------- # _missing_setup_sections # --------------------------------------------------------------------------- @@ -793,6 +831,15 @@ def test_apply_autostart_invokes_toggle() -> None: assert calls == [True] +def test_apply_autostart_returns_real_registration_state() -> None: + # The toggle returns is_registered(); _apply_autostart relays it so + # Settings can reflect / revert the checkbox (T8). + assert mount._apply_autostart(_deps(autostart_toggle=lambda _e: True), True) is True + assert mount._apply_autostart(_deps(autostart_toggle=lambda _e: False), False) is False + assert mount._apply_autostart(None, True) is None + assert mount._apply_autostart(_deps(autostart_toggle=None), True) is None + + def test_apply_autostart_swallows_toggle_failure( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/unit/ui/test_pages.py b/tests/unit/ui/test_pages.py index 7d5d542..354f119 100644 --- a/tests/unit/ui/test_pages.py +++ b/tests/unit/ui/test_pages.py @@ -252,16 +252,15 @@ def test_wizard_run_readme_blocks_until_core_fields() -> None: # --------------------------------------------------------------------------- -def test_settings_eight_sections() -> None: - """Settings has eight sections (Frontend §7.2). +def test_settings_nine_sections_includes_operators() -> None: + """Settings has nine sections (Frontend §7.2 + §7.9 operators). - The ``operators`` section was removed from the UI pending the chip - editor; OperatorsConfig stays in the backend model so the future - re-add is a one-line tuple change. + The ``operators`` chip editor (T7) backs ``OperatorsConfig.allowlist``; + it sits between ``nas_cleanup`` and ``validator`` and is non-gating. """ - assert len(settings.SETTINGS_SECTIONS) == 8 - assert "operators" not in settings.SETTINGS_SECTIONS + assert len(settings.SETTINGS_SECTIONS) == 9 + assert "operators" in settings.SETTINGS_SECTIONS assert settings.SETTINGS_SECTIONS[0] == "paths" assert settings.SETTINGS_SECTIONS[-1] == "application"