Skip to content

feat: rclone.conf-based NAS sync (named remotes, lsjson reconcile, cleanup hash-gate) - #17

Merged
Xander-git merged 74 commits into
mainfrom
fix/debug-with-playwright
May 29, 2026
Merged

feat: rclone.conf-based NAS sync (named remotes, lsjson reconcile, cleanup hash-gate)#17
Xander-git merged 74 commits into
mainfrom
fix/debug-with-playwright

Conversation

@Xander-git

Copy link
Copy Markdown
Collaborator

Summary

Migrates the NAS sync subsystem from inline per-equipment credentials (OS keyring + RCLONE_CONFIG_* env injection) to operator-managed rclone.conf named remotes.

  • Config: new top-level nas: block — remote, base_root, rclone_config_path, mtime_tolerance_s, perf{transfers,checkers}, bandwidth. Per-equipment transport: blocks and the NAS keyring are gone; equipment entries carry just id/label/local_root/nas_root/sync_mode.
  • DRY rclone surface: every rclone op is a reusable RcloneDriver method (copy/check/about/lsjson/listremotes); nothing else shells out to rclone.
  • Verify model: routine post-push reconcile via read-only rclone lsjson (credited when present AND size-equal AND modtime within mtime_tolerance_s); the expensive rclone check --download hash-verify runs only as the integrity gate immediately before local deletion at cleanup (streams + hashes in memory, no data staged to disk) and on manual force-verify.
  • Setup/UI: setup gate keys on rclone remote availability (incomplete_no_nas_remote / configure_rclone_remote); Settings shows a read-only "NAS Remote" status + "Test connection" (rclone about <remote>:). No NAS password entry.
  • Orchestrator stage-mode: staging hop also moves to rclone (orchestrator.staging_remote/staging_base_root/staging_perf), reusing the same driver ops + target helper.
  • Tunable --transfers/--checkers (also the memory dial for space-constrained acquisition machines).
  • Clean break: removed the transport union, NAS keyring helper, and obscure/build_rclone_env/env-injection plumbing.
  • Docs: new operator guide docs/setup/rclone-remote-setup.md + updated design-spec sections (04/07/09) + README.

Design & plan: docs/superpowers/specs/2026-05-28-rclone-conf-nas-sync-design.md, docs/superpowers/plans/2026-05-28-rclone-conf-nas-sync.md.

Test plan

  • Focused suites green: uv run pytest tests/unit/config tests/unit/sync tests/unit/api tests/unit/tray tests/unit/ui tests/unit/orchestrator tests/integration -q1230 passed, 3 skipped
  • uv run ruff check src/exlab_wizard tests + uv run mypy src/exlab_wizard → clean
  • DRY guard: grep -rnE "run_subprocess|['\"]rclone['\"]" src/exlab_wizard | grep -v sync/transports/ → zero
  • Removal guard: dead symbols (keyring_nas_username, build_rclone_env, RCLONE_CONFIG_, transport models, etc.) → zero in src
  • e2e collects; test_flow_27 parked (skip)

Known follow-ups (non-blocking)

  • e2e test_flow_27_nas_credential_settings.py parked behind a skip (behavior covered at unit/integration).
  • Dead keyring_store constructor param on NASSyncClient; two stale docstrings.
  • Reconcile re-queue is unbounded (cheap/self-healing) if a file's modtime permanently drifts past tolerance — no terminal escalation.
  • Pre-existing whole-suite "Event loop is closed" async-teardown contamination is unrelated to this work (affected modules pass in isolation).

Note: this branch also contains pre-existing fix/debug-with-playwright commits that predate the migration.

🤖 Generated with Claude Code

Xander-git and others added 30 commits May 22, 2026 14:42
Replace the equipment-written completeness signal (sentinel file /
manifest) with a background quiescence poller that syncs each file
independently once it has been observed unchanged for a settle window
(default 10 min) -- no equipment cooperation, no operator marking.

- config: drop per-equipment completeness_signal/sentinel/manifest; add
  sync.quiescence_minutes / ignore_globs / poll_interval_seconds. The
  Add-Equipment wizard goes 5 -> 4 steps.
- new per-run sync_state.json (per-file synced_signature / verified_at /
  keep_local, plus cleared_at); ingest.json / IngestWriter / IngestState
  removed.
- QuiescenceSyncPoller replaces StagingWatcher; discovers both
  staging-area and nas-mode runs.
- per-file sync through the SyncQueue (new files column), transports
  gain --files-from, verifier gains a subset filter, per-file verify
  reconciliation credits each verified file into sync_state.json.
- cleanup honors per-file keep-local and is symlink-safe; the run
  rollup (syncing/synced/cleared) is derived from sync_state.json.
- GUI: per-file sync status including "On NAS" tombstones for cleared
  runs, plus a keep-local toggle and its API endpoint.
- wire the sync pipeline that was never actually started in production
  -- the poller and the NASSyncClient worker now run via the app
  lifespan.

Also includes earlier same-session fixes: the Add-Equipment wizard's
reactive Next button, persistent multi-step state, and working confirm;
the encrypted-at-rest keyring fallback (EXLAB_WIZARD_SECRET_PASSPHRASE);
and a repo-wide lims_password_present accessor.

Design: docs/superpowers/specs/2026-05-21-operator-free-per-file-nas-sync-design.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add EXLAB_WIZARD_TEST_MODE env (and unify it with the existing
  --test tray flag): when set, every loaded equipment.id gains a
  TEST_ prefix at config-load time. Run dirs and NAS paths inherit
  it, so test runs land under <nas_root>/TEST_<id>/... -- trivially
  identifiable and bulk-deletable. Idempotent; re-validates the
  rewritten config.
- TEST_MODE_ENV / TEST_MODE_PREFIX constants in constants/app.py,
  shared with paths.py.
- Rename the test app's seeded equipment to TEST_EQ1 / TEST_RELAY_EQX
  to match the convention; cascade through 9 e2e flow files.
- File list: add NAME / SIZE / MODIFIED / STATUS column headers in
  proper <thead>/<tbody>.
- Test app: keep-local context action now mutates the seeded feed
  and re-renders so the badge visibly toggles (production routes the
  same action through SyncStateWriter.set_keep_local).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migrate the NAS sync subsystem off rsync-over-SSH (key-only) and the
rclone.conf-based remote to a single rclone-binary path with inline-
configured SFTP and SMB backends, password auth via the existing
KeyringStore, and a hybrid integrity model (full SHA verify on freshly
synced files via `rclone check --download --files-from`, size+mtime
trust in steady state). Adds Slot A: every per-file sync_state.json
record gains `verified_sha256` so the offline-audit affordance lost by
dropping `.exlab-wizard/checksums.sha256` is recovered. Slot B (scheduled
drift audit) deferred to its own spec.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Foundation of the rclone-only NAS sync migration (see
docs/superpowers/specs/2026-05-26-rclone-only-nas-sync-design.md):

- Config: replace RcloneTransport + RsyncSshTransport with
  RcloneSftpTransport + RcloneSmbTransport (password-based) and add
  transport_requires_keyring_password() predicate.
- Transports: delete rsync_ssh.py; rename RcloneTransport->RcloneDriver;
  add check/about/obscure methods + build_rclone_env helper; keep
  legacy hashsum() alive at the phase boundary (Phase 2 deletes it).
- _run.py: extend run_subprocess with env / stdin / mask_for_log
  forwarding so RCLONE_CONFIG_<remote>_PASS injection works and
  obscured passwords are redacted from debug logs.
- nas_client: thread keyring_store through NASSyncClient ctor and the
  push/hashsum factories; each closure now fetches the per-equipment
  password fresh, runs obscure, builds env, and dispatches.
- Wizard + Settings: new SFTP/SMB transport radio with conditional
  fields; review-step banner directs operator to Settings to set the
  NAS password.
- Test fixtures: stub_rclone gains obscure/about/check behaviors plus
  env-dump probe; stub_rsync deleted; YAML fixtures updated.
- 2162 unit+integration tests pass; mypy + ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Address the Phase 1 code-review findings:

- nas_client._resolve_env_for_equipment: surface keyring-backend
  exceptions as AUTH-class TransportError with __cause__ attached,
  instead of silently swallowing them and misreporting as "password
  not set". Also explicitly treat empty-string passwords as missing.
- enums.TransportType: replace legacy RCLONE / RSYNC_SSH members with
  RCLONE_SFTP / RCLONE_SMB so the enum reflects the post-migration
  transport set.
- settings.py: refresh the equipment-section docstring to name the
  new transport radio (rclone_sftp / rclone_smb) and the post-
  registration credential flow.
- tests: new tests/unit/sync/transports/test_run.py covering env
  forwarding, stdin piping, and mask_for_log redaction; new
  test_rclone_env.py covering the exact env-key set per backend, the
  conditional SMB _DOMAIN key, and the four AUTH paths (None
  keyring_store, None password, empty password, broken keyring).

2176 unit+integration tests pass; mypy + ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the Python SHA pipeline with `rclone check --download
--combined` as the integrity authority, and add Slot A: a durable
per-file SHA captured at sync time and recorded in sync_state.json.

- Verifier (verifier.py): rewritten as a thin adapter that translates a
  CheckResult (from RcloneDriver.check) into the queue-worker's
  VerifyResult shape. Deleted compute_local_manifest,
  verify_against_local, verify_against_remote, format_manifest,
  parse_manifest, plus the durable <run>/.exlab-wizard/checksums.sha256
  artefact + CHECKSUMS_RELATIVE constant.
- FileSyncRecord (sync_state_schema.py): adds verified_sha256: str |
  None. upsert_file accepts the new kwarg; the writer preserves the
  prior digest when called without it so re-verify passes don't blank
  out the audit trail (synced_signature / verified_at keep
  None-overwrites-existing for the rollup-back-to-syncing path).
- nas_client._drive_job: re-ordered as (existence checks) -> (Slot A
  local SHA) -> (push) -> AWAITING_VERIFY -> (rclone check) ->
  (reconcile from VerifyResult.verified, crediting verified_sha256 from
  Slot A). _build_transport_driver now returns (driver, push, check);
  _build_hashsum_callable / _verify_pass / _build_hashsum deleted.
  force_verify reports only -- never updates verified_sha256.
- _discover_run_files: helper that synthesises a whole-run files-from
  list for enqueues that didn't supply one (force-sync, first-time
  poller sweep), so the verify path always has a concrete subset.
- Transport driver: RcloneDriver.hashsum deleted (legacy probe path).
- Stub rclone: drops hashsum behaviors; keeps check + obscure + about.
- Tests: deleted test_verifier.py (18 tests for the deleted SHA
  pipeline); new test_rclone_env.py for build_rclone_env / AUTH paths;
  test_run.py for env / stdin / mask_for_log; refactored
  test_nas_client(_extra).py to use check_callable_factory; _helpers.py
  gains local_check_factory and corrupt_one_check_factory.
- 2155 unit+integration tests pass; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…A assertions

Address the Phase 2 code-review findings:

- sync_state_writer.upsert_file docstring: rewrite to spell out the
  asymmetric None handling -- synced_signature / verified_at always
  overwrite (poller re-modified flow), verified_sha256 preserves the
  prior digest when None (audit-trail survival).
- nas_client._drive_job docstring + retry-routing comment: drop stale
  references to deleted _verify_pass / verify_against_remote /
  remote hashsum probe; name the new rclone check path.
- stub_rclone.py: delete the legacy hashsum verb dispatch, the
  _emit_hashsum helper, the hashsum_success behavior token, the
  STUB_RCLONE_HASHSUM_PATH env probe, and the orphan hashlib import.
  The new world has no hashsum invocations to stub.
- test_nas_client_extra batch tests: add verified_sha256 assertions
  on credited records so the Slot A capture path is pinned at
  assertion level (was implicitly trusted via shape checks before).

2155 unit+integration tests pass; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add INCOMPLETE_NO_NAS_CREDENTIAL setup state between NO_EQUIPMENT and
NO_LIMS: every nas-mode equipment whose transport requires a keyring
password must have its entry before the app is READY. The state maps to
a SET_NAS_CREDENTIALS next-action and per-equipment missing-field rows.

Wire deps.nas_password_present (hydrated at tray boot from the keyring)
and a real deps.equipment_probe that obscures the keyring password,
builds the inline rclone env, and runs `rclone about`. POST
/setup/test-equipment now requires equipment_id (the pre-save
body-equipment path is impossible without a stored password) and 404s
on unknown ids.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Hoist `import time` to module top in tray/dependencies.
- Add `pass_env_keys_for` to rclone.py __all__ (it is part of the
  log-redaction security surface, imported by name by the probe).
- Extend the setup-state-gate test to cover the NAS-credential and
  LIMS hard-block states (previously stopped at no_equipment).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a per-equipment NAS-credentials section to Settings, shown only when
password-requiring nas-mode equipment exists (inserted after Equipment
List). Each row pairs a keyring-backed credential field (writing through
per-equipment Save/Clear handlers that also flip the live
deps.nas_password_present set) with a Test connection button that runs
the rclone probe and renders the result inline.

The setup-incomplete banner subline is now next-action aware so the
INCOMPLETE_NO_NAS_CREDENTIAL state points the operator at the new
section, and _missing_setup_sections surfaces it so opening Settings
auto-selects it.

Adds unit coverage for section visibility/rendering and the mount
handlers, plus e2e flow 27 driving the full set -> ready -> test -> clear
round-trip against the production app. Also refreshes the stale
EquipmentWizardState seed in the e2e test app.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- first_incomplete_section now folds the dynamic NAS-credentials section
  into the canonical order (after equipment), so an
  INCOMPLETE_NO_NAS_CREDENTIAL install auto-selects it instead of landing
  on the default section.
- _is_setup_ready mirrors the NAS-credential gate (via shared
  _nas_credential_missing helper) so a registered-but-uncredentialed NAS
  equipment keeps the setup-incomplete banner up and the index redirect
  correct, instead of reading as ready on LIMS alone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…se 5)

Sweep the legacy transport out of the test surface:
- stub_rclone gains STUB_RCLONE_REQUIRE_ENV to assert the production
  path injects the full inline backend env; the integration happy-path
  asserts TYPE/HOST/USER/PASS were injected.
- e2e flow_00 lifecycle + flow_26 wizard now register rclone_sftp /
  rclone_smb equipment (was rsync_ssh / rclone_remote), and flow_00 sets
  NAS passwords post-restart so creation clears the new gate.
- wizard_equipment page object exposes the SFTP/SMB fields; ux_catalog
  swaps the rsync entries for the SFTP/SMB + NAS-credentials affordances
  (UX_INTERACTIONS.md regenerated).
- ProdServer accepts extra_env so the lifecycle fixture pins the keyring
  to the encrypted fallback.

Convert tests/docker/ from SSH-key auth to password auth: SFTP
(PasswordAuthentication) + a new Samba SMB service, password set at boot
from NAS_PASSWORD, keys/ removed, README/compose/.env/rclone.conf
rewritten for the rclone-only model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- entrypoint.smb.sh: smbpasswd -a crash-looped on restart because the
  Samba TDB persists in the writable layer; fall back to a plain
  password change when the user already exists.
- Add a :445 healthcheck to the nas-smb service so `docker compose ps`
  reports readiness.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extract VerifyResult.from_check_result so the three identical
CheckResult->VerifyResult translations (Verifier.verify, force_verify,
the _drive_job worker) share one source of truth, and drop the never-read
`verifier` constructor param + self._verifier field NASSyncClient carried
since the Phase 2 collapse.

Other simplify-pass targets (shared password+obscure helper, build_rclone_env
per-backend DRY, push-vs-verify error classification) were deliberately
left: their apparent duplication guards genuinely divergent error contracts
and would trade readability for a few saved lines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Settings "Test connection" probe passed the bare remote name to
`rclone about`, which real rclone reads as a local path and fails with
"directory not found". Pass `<remote>:` so it targets the SFTP/SMB
backend root. Found during live-NAS verification against the Docker
fixture; the stub had masked it by ignoring the remote argument.

The stub's `about` verb now rejects a colon-less remote, and a new
test drives the probe end-to-end through the stub so this can't regress.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The SFTP/SMB compose healthchecks call `ss`, which debian-slim does not
ship; both containers reported "unhealthy" despite working. Add iproute2
to both images. Verified: `docker compose ps` now shows healthy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`_is_setup_ready` (drives the `/` index route and
`MainPageState.setup_incomplete`) was a hand-rolled mirror of
`compute_setup_state` that only recognized the LIMS *keyring* branch.
For a disconnected-workstation install whose LIMS slot is satisfied by
`offline_catalogue_path` (no stored password), it returned False even
though `GET /setup/status` reported `ready` -- so `/main` kept the
setup-incomplete banner up perpetually. The banner subline already
delegated to `compute_setup_state` via `_setup_next_action`, so the two
disagreed: banner shown, but with the generic subline (next_action None).

Delegate `_is_setup_ready` to `compute_setup_state` -- the single source
of truth the `/setup/status` endpoint and route gate use -- so the main
page agrees with the API across every §4.9 gate (paths, orchestrator,
equipment, NAS credentials, and both LIMS branches). Best-effort: any
evaluation failure degrades to "not ready" so a half-wired backend keeps
the operator on the onboarding path.

Verified live against the production app seeded with the exact
offline-catalogue config: `/main` now renders the file explorer with no
banner, matching `/setup/status: ready`.

Tests reworked to exercise the real evaluator (the old block leaned on
the narrow mirror ignoring equipment/plugin_dir) and add an
offline-catalogue readiness regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make orchestrator.staging_root optional. Blank = the device cannot act as
a staging PC; nothing is created. label stays required. No /staging
directory is ever auto-created without the operator specifying a path; a
specified path is created on Settings save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live config reload — apply config changes in-process instead of forcing a
tray relaunch. Settings save now pushes the validated config into the
running components via tray.dependencies.apply_live_config, which fans out
to apply_config()/reconfigure() on the quiescence poller, NAS sync client,
controller/creation, and validator engine. The restart-required gate and
relaunch screen are removed from ui.mount.

Opt-in staging_root (design: docs/superpowers/specs/2026-05-28-staging-root-opt-in-design.md):
- staging_root no longer gates setup; only orchestrator.label is required
  (it identifies the workstation in every run's creation.json). A device
  with a blank staging_root simply is not a staging PC.
- Replace the dead /staging default with paths.suggested_staging_root(): a
  pure, side-effect-free OS-appropriate suggestion shown only as the
  Settings placeholder, nested under exlab-wizard/ (never a bare /staging).
- The staging directory is created only when the operator saves a non-empty
  path (ui.mount._ensure_staging_root); a creation failure is non-fatal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds docs/REMAINING_WORK.md (verified audit of unimplemented / unwired features, categories A-D) and docs/REMAINING_WORK_TASKS.md (13 tracked tasks T1-T13 with status + implementation-note slots to mark complete as integrated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Production built the creation controller without a readme_generator, so
GUI-created projects/runs ran on NoOpReadmeGenerator and shipped a 3-line
stub README with no YAML front matter and no readme_fields.json cache.

- Inject ReadmeGenerator() in tray.dependencies._build_controller (the
  sole production constructor; covers the lifespan build and the
  apply_live_config fresh-build branch).
- Replace the controller's flat placeholder ReadmeContext with the
  canonical layered type from exlab_wizard.readme; the controller's
  ReadmeGeneratorProtocol and NoOpReadmeGenerator now use the
  tuple[Path, Path] contract.
- Add CreationController._build_readme_context: partitions readme_extra
  across the template/config/custom layers by id, maps template + config
  field declarations, and fills the §10.6 system block (created_by = OS
  user, project = folder name, 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.
- Add end-to-end test asserting four-layer front matter + readme_fields.json.

https://claude.ai/code/session_01AeEGo2KMn5xq7UNDuap1Wn
Code review flagged that the §10.6 system block's `project` field must be
the machine-safe LIMS short id (e.g. PROJ-0042) recorded in README metadata
(§3.1), not the human-readable <project>/ folder segment. Use _short_id_for
(matching the original flat ReadmeContext) and correct the acceptance test.

https://claude.ai/code/session_01AeEGo2KMn5xq7UNDuap1Wn
The Confirm & Create progress bar was static (active_phase=None): nothing
consumed CreationController.subscribe(), and the session_progress component
keyed two of its six phases on the wrong strings.

- Fix phase-name mismatch: session_progress PHASES/PHASE_LABELS used
  'post_validation'/'queueing_sync' while the controller emits the
  wire-format 'validating_post_creation'/'queueing_nas_sync'
  (state_machine.Phase). Align the component to the wire-format so a live
  phase frame maps onto a row without translation.
- Add SessionProgressState + apply_frame(state, frame): folds phase/
  progress/done frames into render args; ignores input_required (T5) and
  unknown phases.
- Render the confirm-step bar through a @ui.refreshable bound to
  state.progress, exposing state.progress_refresh.
- mount._run_creation consumes controller.subscribe() via
  _consume_session_progress while the pipeline runs; race-free because
  _launch creates the event queue before the pipeline starts and the
  queue buffers early phases.

The controller emits no 'progress' frame yet, so the §9.3 per-plugin
sub-row stays dormant until the plugin host emits one (handled defensively).

https://claude.ai/code/session_01AeEGo2KMn5xq7UNDuap1Wn
The operations_modal component was built and exported but rendered
nowhere; there was no way to inspect in-flight sessions from the GUI.

- Add SessionStore.iter_sorted() (public, sorted accessor) and a shared
  project_identifier(request) helper; refactor the /operations route onto
  them so neither the route nor the UI reaches into store._sessions, and
  rows are labelled identically.
- Add OperationRow.from_session(): maps the §4.7 state machine onto the
  panel's running/suspended/completed buckets.
- main.py: [Operations…] toolbar button (visible when >0 in flight,
  warning-colored when any need input) and a footer Sync segment that
  flips to 'N operations need input' and opens the same modal (§3.5.5);
  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. Resume/cancel are baseline here (cancel keeps files); the §9.4
  dialog and §9.6 disable rule land in T4, the input dialog in T5.
- Fix a latent circular import: operations.py now imports SessionState /
  project_identifier from the controller submodules, not the package.

https://claude.ai/code/session_01AeEGo2KMn5xq7UNDuap1Wn
- _cancel_operation opens a Discard/Keep confirm dialog mapped to
  controller.cancel(id, discard_files=...): Discard removes the partial
  directory (shutil.rmtree), Keep leaves it as an orphan. Errors are
  toasted; cancel is a no-op on an already-terminal session.
- §9.6 single-equipment concurrency: _operation_counts now also reports
  an 'active' (strictly non-terminal) count; _build_main_state sets
  MainPageState.creation_in_flight, and the New Project / Run / Test Run
  buttons disable (with a tooltip) while any creation is in flight.
- Tests for _operation_counts (panel vs active vs input_required).

Resume row action still routes through the INPUT_REQUIRED dialog landing
in T5.

https://claude.ai/code/session_01AeEGo2KMn5xq7UNDuap1Wn
When a plugin suspended mid-creation the wizard hung indefinitely with no
way to answer; only the HTTP /resume route could.

- New component ui/components/input_required_dialog.py: the §9.1
  'Additional input required' dialog -- plugin pill, reason line, one
  widget per pending_input field (string/text/choice/boolean) two-way
  bound to a values dict; persistent so it must resolve before the next
  frame (§9.2).
- The T2 progress consumer opens the dialog on an input_required frame
  and force-closes it on a terminal done/failed frame (plugin timeout).
  Submit -> controller.resume(sid, values) (errors surfaced as a toast;
  plugin re-rejection re-emits input_required and re-opens the dialog),
  Cancel -> the §9.4 cancel dialog.
- Split _cancel_operation into a controller-driven _cancel_session core
  (reused by the dialog) plus a deps resolver; the Operations modal's
  Resume reads the parked pending_input and re-opens the same dialog.

https://claude.ai/code/session_01AeEGo2KMn5xq7UNDuap1Wn
Settings had no editor for OperatorsConfig.allowlist (the controller's
allowlist gate was a permanent no-op for GUI users) and rendered
content_scan_extensions read-only.

- Add a reusable _render_chip_editor(values, ...) in settings.py
  (add / per-chip delete / optional reset; mutates the draft list in
  place so persistence rides the existing draft -> finalize -> Save path).
- T7: add 'operators' to SETTINGS_SECTIONS (between nas_cleanup and
  validator) + SECTION_TITLES and an operators section with §7.9 helper
  text + a chip editor bound to draft.operators.allowlist. Stored
  verbatim (case-sensitive; trimmed on add). Non-gating.
- T10: replace the read-only extensions label with the same chip editor
  bound to draft.validator.content_scan_extensions, with Reset to
  defaults and an on-add '.'-prefix validator.
- Update the settings section-count test (8 -> 9, operators present).

https://claude.ai/code/session_01AeEGo2KMn5xq7UNDuap1Wn
- Consumer leak (Major): the abandoned-session GC closed INPUT_REQUIRED
  sessions directly on the store, bypassing the controller's _publish, so
  the in-process subscribe() consumer parked forever on queue.get(). The
  GC now pushes a terminal {kind: failed} frame onto the session's event
  queue so the wizard progress loop wakes and exits.
- FAILED mislabeled (Major): OperationRow.from_session mapped FAILED to
  the running bucket (play glyph, no action). Add a STATE_FAILED bucket
  (error glyph) so a failed op is labelled distinctly; sort places it
  after running, before completed.
- Footer Sync segment is only clickable when operations_count > 0, so a
  click never opens an empty Operations panel.
- Fix an inaccurate _open_input_required_dialog docstring (resume does
  not reject an empty payload).

Tests for the FAILED bucket and the GC terminal-frame wake-up.

https://claude.ai/code/session_01AeEGo2KMn5xq7UNDuap1Wn
The Application section's three controls were inert: the autostart
checkbox was unbound, the tray-status label was a static literal, and
the Quit button had no handler.

- T8: tray/dependencies seeds deps.autostart_is_registered; _apply_autostart
  returns the toggle's real is_registered(); the checkbox seeds from it and
  applies immediately on change (exempt from the draft, §7.13), reverting to
  the actual post-op state on mismatch.
- T9: the tray builder attaches deps.request_quit = tray_app.request_quit;
  the Quit button is gated behind a confirm and scheduled non-blocking via
  ui.timer so the HTTP response flushes before shutdown.
- T11: the tray builder sets deps.tray_available from a pystray-import
  probe; the label reflects available / unavailable (window-only) and the
  window-on-close behavior copy is added.
- AppDependencies gains typed autostart_is_registered / request_quit /
  tray_available fields. Controls disable cleanly when their hook is absent
  (headless / tests).

https://claude.ai/code/session_01AeEGo2KMn5xq7UNDuap1Wn
The Problems tab badge and right-pane summary were hardcoded to 0 and
the /problems footer showed 'Last audit: --'; nothing read the audit.

- The 30s background _audit_loop now caches tier counts on
  deps.last_audit_hard / last_audit_soft (single source; avoids a
  per-render O(tree) re-audit). _build_main_state reads them into
  MainPageState.problems_count_hard/soft, so the tab badge and the
  right-pane summary are real (right-pane no longer hardcodes 'Showing 0').
- render_problems_page takes last_audit_at and renders
  'Last audit: HH:MM:SS · Next refresh in Ns' with a 1s ui.timer
  countdown. start_audit_task=True confirmed in the tray build.
- AppDependencies gains typed last_audit_hard/soft fields.

Deferred (documented in the tracker): wiring the §11.5 override action and
the full live WS-delta stream -- render_problems_page expects a view-model
shape the raw Validator Finding doesn't provide (a pre-existing, e2e-only
mismatch); reconciling that + the in-process override-write path is a
follow-up. The counts/last-audit core lands here.

https://claude.ai/code/session_01AeEGo2KMn5xq7UNDuap1Wn
Xander-git and others added 28 commits May 29, 2026 00:04
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 4 of the rclone.conf NAS-sync migration. The routine post-push
verify no longer downloads and rehashes every file via
``rclone check --download``; it now lists the remote subtree with
``rclone lsjson`` and credits each file whose remote size + modtime
match local (within ``nas.mtime_tolerance_s``). The expensive
download-and-rehash moves to a single integrity gate run immediately
before any irreversible local deletion in ``_maybe_cleanup``.

- Build the rclone target from the ``nas:`` block (named remote +
  base_root) via ``_build_target_for_run`` / ``_target_for_equipment``;
  the driver is constructed from ``nas.rclone_config_path`` + perf dials
  (``_build_driver``). Credentials now live entirely in the operator's
  rclone.conf; the keyring/env threading is gone.
- ``_drive_job``: after a successful push -> AWAITING_VERIFY, fetch the
  lsjson manifest and reconcile per file. A complete reconcile promotes
  to VERIFIED; an incomplete one re-queues (``remote_reconcile_incomplete``)
  with no backoff so the next sweep re-pushes the laggards. A new
  ``_handle_verify_transport_error`` preserves the spec §7.1.5 routing
  for lsjson transport failures (AUTH terminal, NETWORK/UNKNOWN backoff).
- ``_maybe_cleanup``: after the §7.1.6 interlocks pass, run a two-stage
  gate over the tracked files -- an lsjson existence probe then
  ``rclone check --download``; any failure defers in CLEANUP_ELIGIBLE
  rather than deleting.
- Drop the now-dead ``_resolve_env_for_equipment`` /
  ``_build_transport_driver`` / ``_build_target_for`` /
  ``_reconcile_synced_files``. Keep ``_remote_name_for`` and the
  (now unused) ``keyring_store`` ctor arg for additive compatibility
  with the not-yet-migrated tray surface.
- ``Verifier.verify`` drops the ``env`` / ``mask_for_log`` params.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-gate

Update the sync unit + integration suites for the named-remote migration.

- ``_helpers.py``: add ``local_lsjson_factory`` / ``missing_one_lsjson_factory``
  returning a ``RemoteManifest`` that mirrors (or omits one of) the local
  files' size + modtime, for injecting via the new ``lsjson_callable_factory``.
- ``test_nas_client.py``: drive the routine path through the lsjson
  reconcile; add ``test_routine_reconcile_marks_synced_from_lsjson``
  (asserts the hash-verify ``check`` is NOT called on the routine path)
  and ``test_routine_reconcile_requeues_when_remote_file_missing``.
- ``test_nas_client_extra.py``: re-point the cleanup tests at the lsjson
  factory + hash-gate; replace the old verify-retry-via-check tests with
  ``test_cleanup_runs_hash_gate_before_delete`` /
  ``test_cleanup_aborts_delete_on_hash_mismatch``; recast the partial
  batch test as a reconcile-incomplete (re-queue) case. Drop the deleted
  ``_build_transport_driver`` tests.
- ``test_rclone_env.py``: drop the deleted ``_resolve_env_for_equipment``
  AUTH-path tests; keep the ``build_rclone_env`` / ``pass_env_keys_for``
  coverage (those helpers still back the tray probe path).
- ``test_nas_sync.py`` + ``stub_rclone.py``: add a ``nas:`` block to the
  config and ``lsjson`` support to the stub; migrate the integration
  flows to the reconcile + cleanup-gate model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tring

Add ``test_cleanup_aborts_delete_when_remote_file_vanished_at_gate``: a run
reconciles to VERIFIED normally, but the cleanup-gate lsjson existence
probe omits a tracked file (present at reconcile, gone at cleanup). A
stateful lsjson factory returns the full manifest on the first (reconcile)
call and a missing-one manifest on subsequent (cleanup-probe) calls. The
injected hash-gate check would pass, so a CLEANUP_ELIGIBLE result can only
come from the existence probe -- asserting the run defers and the local
data file is not deleted.

Also fix the stale docstring in ``test_default_push_factory_uses_real_driver``
which still referenced the removed ``_build_transport_driver``; it now
describes the default ``_build_push`` -> ``_build_driver`` path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the per-equipment NAS-keyring setup gate with one that depends on
the configured nas: remote being present in rclone.conf. evaluate_setup_state
now takes nas_remote_available; setup_state_missing reports nas.remote.
Thread the new predicate through api/setup, api/routers/config, and add the
nas_remote_available helper + AppDependencies fields the callers read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Test connection" probe now runs `rclone about <nas.remote>:` through
the driver (no keyring), and boot hydrates deps.nas_remotes via
RcloneDriver.listremotes() so deps.nas_remote_available drives the §4.9
setup gate. Keyring-presence plumbing (_check_nas_passwords_present,
_nas_keyring_password) is retained for the not-yet-migrated Settings UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The api deps now expose nas_remotes + nas_remote_available and the
_dependencies.nas_remote_available reader; setup/status and the config
router thread the predicate into the §4.9 evaluator so GET /setup/status
returns next_action == "configure_rclone_remote" when a nas-mode device's
remote is absent from rclone.conf. (Deps fields + helper + api threading
landed with the paths-gate commit; this finalizes the tray docstring that
no longer feeds the gate.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Phase 9)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ransport

rclone.conf NAS-sync migration (Phase 6, Task A). The nas: block now
carries the single rclone-remote connection, so nas-mode equipment no
longer requires (nor forbids) a per-equipment transport block. A
still-declared transport is allowed for backward compatibility; stage-mode
rules are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 6 (Tasks B–D). Replace the per-equipment NAS-credential UI with the
single rclone-remote model:

- Settings: rename the "NAS Credentials" section to "NAS Remote"
  (NAS_REMOTE_SECTION). The new read-only section shows the configured
  nas.remote + base_root, a found/not-found badge from
  nas_remote_available, and a single Test-connection button. No password
  input, no per-equipment rows. render_settings_page drops
  nas_password_present_for / nas_credential_handlers / on_test_equipment in
  favour of nas_remote_available + on_test_connection.
- Equipment add-form (Settings + wizard): drop the SFTP/SMB transport
  radio/fields for nas-mode; the nas-mode build path now sets
  transport=None.
- mount.py: delete _nas_credential_handlers; replace _nas_credential_missing
  with _nas_remote_missing (gates on nas.remote availability); the readiness
  missing-list appends "nas_remote". _nas_test_connection now probes the
  single nas: remote.
- Remove the keyring plumbing parked in Phase 5: nas_password_present reader
  (api/_dependencies), the nas_password_present deps field (api/app), and
  _check_nas_passwords_present / _nas_keyring_password / the hydration block
  (tray/dependencies). keyring_nas_username stays defined (Phase 7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rename test_settings_nas_credentials.py -> test_settings_nas_remote.py and
  rewrite for the read-only NAS Remote section: renders the configured
  remote + base_root, found/not-found badge from nas_remote_available, a
  single Test-connection control, and asserts NO password input (scoped to
  the section body).
- test_mount.py: drop _nas_credential_handlers tests; replace the
  nas_credentials missing-section tests with _nas_remote_missing cases
  (remote unavailable / unset / available).
- test_wizard_equipment.py + test_dynamic_form.py: drop the removed
  SFTP/SMB transport inputs; nas-mode now builds transport=None; stage-mode
  staging transport path retained.
- test_pages.py: first_incomplete now resolves "nas_remote".
- Strip the now-dead nas_password_present kwarg from api/tray/integration
  test AppDependencies constructions; remove _check_nas_passwords_present
  tray tests + unused keyring_nas_username import.
- Add config test_nas_mode_equipment_needs_no_transport_block (Task A) plus
  a back-compat test that a still-declared nas-mode transport validates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rclone.conf NAS-sync migration Phase 7.1. NAS sync is now purely
rclone.conf-driven, so the per-equipment transport machinery is gone:

- Delete RcloneSftpTransport, RcloneSmbTransport, the EquipmentTransport
  discriminated union, and transport_requires_keyring_password from
  config.models; drop EquipmentConfig.transport.
- Simplify the EquipmentConfig model_validator: nas-mode imposes no
  transport requirement; stage-mode still requires
  orchestrator_staging_transport.
- Remove the now-unused TransportType enum and its constants exports.
- Fix SRC callers: nas_client sources bandwidth from the nas: block;
  the UI equipment-form builder and tray sample-config no longer set
  transport; api/setup drops the dead EquipmentTransport holdover.
- Strip stray transport: blocks from fixtures/tests and move the
  complete.yaml bandwidth policy onto the nas: block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rclone.conf NAS-sync migration Phase 7.2. NAS credentials live entirely
in the operator's rclone.conf, so the per-call credential plumbing is
gone:

- rclone driver: delete build_rclone_env, pass_env_keys_for, and the
  obscure helper; drop the env / mask_for_log params from push, check,
  and about.
- _run.run_subprocess: reduce to a plain spawn helper (no env override,
  stdin piping, or log redaction).
- constants: delete keyring_nas_username and KEYRING_USERNAME_NAS_TEMPLATE
  (LIMS keyring helpers untouched); drop the dead _remote_name_for in
  nas_client.
- tests: git rm test_rclone_env; rewrite test_run + drop the obscure /
  env-injection tests in test_transports; strip the obscure verb and
  env-probe handling from the rclone stub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the per-equipment mount-based orchestrator_staging_transport
block with a global orchestrator.staging_remote / staging_base_root /
staging_perf. stage-mode equipment now carry no per-equipment connection
block; the staging hop is a second named rclone remote in the same
rclone.conf as the nas: remote.

- OrchestratorConfig gains staging_remote, staging_base_root, staging_perf
- Delete OrchestratorStagingTransport model + OrchestratorTransportType enum
- Drop EquipmentConfig.orchestrator_staging_transport and its now-trivial
  sync-mode validator (extra="forbid" rejects any stray block)
- UI: wizard / equipment_form no longer collect stage-mode mount fields
- Adapt config / constants / ui / orchestrator tests to the new model

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Branch the per-equipment target/driver by sync_mode, reusing the same
RcloneDriver ops and _build_target_for_run helper as the NAS leg (no new
rclone operation):

- _target_for_equipment: stage-mode -> orchestrator.staging_remote +
  staging_base_root; otherwise the nas: block (unchanged)
- _driver_for_equipment: stage-mode uses orchestrator.staging_perf,
  sharing nas.rclone_config_path (staging + nas remotes live in the same
  rclone.conf); nas-mode uses nas.perf
- _build_lsjson strip_prefix uses the per-equipment base root
- _build_driver refactored to (config_path, perf)

Tests: stage-mode target -> stagepc:/staging/<eq.id>/<run.name>,
nas-mode target unchanged, stage-mode driver picks staging_perf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e new gate

Add NasConfig(remote="nas01", base_root="/srv/nas") to the ready_config
fixture so all nas-mode equipment in the integration tests satisfy the
INCOMPLETE_NO_NAS_REMOTE setup gate introduced by the rclone.conf migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a listremotes verb that prints remotes from STUB_RCLONE_LISTREMOTES
env (default "nas01:"), one per line, mirroring the real rclone output.
The --config flag was already tolerated (it appears in flags_with_value).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace stale settings-nav-nas_credentials / incomplete_no_nas_credential /
settings-nas-test-EQ1 references with the new settings-nav-nas_remote /
incomplete_no_nas_remote / settings-nas-test-connection equivalents.

- test_flow_00_full_lifecycle.py: Phase 6 updated to navigate to
  settings-nav-nas_remote section (read-only) instead of setting per-
  equipment passwords.
- test_flow_27: docstring + module skip reason refreshed; test renamed
  to test_nas_remote_gate_configure_and_test; body rewritten to drive the
  read-only NAS Remote section; Phase 9B TODO left for the full wiring.
- ux_catalog.py: NAS Credentials entries replaced by NAS Remote entries
  (settings-nav-nas_remote, settings-nas-remote-name,
   settings-nas-test-connection).
- docs/UX_INTERACTIONS.md regenerated from the updated catalog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Provides operator-ready [nas01-sftp] and [nas01-smb] stanzas pointing at
the compose service hostnames (localhost:2222 / localhost:1445) for manual
and dockerised integration runs. Password lines are commented out so the
file is safe to commit; operators fill in `rclone obscure` output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add test_drive_job_bandwidth_comes_from_nas_block to verify that
_drive_job derives its bwlimit_kibps from config.nas.bandwidth (the global
nas: block), not from any per-equipment source.

Uses a recording push factory that captures bwlimit_kibps on each push
call and asserts it equals effective_bandwidth_limit_kibps(nas.bandwidth).
BandwidthConfig(upload_mbps=8.0) is schedule-free so the cap applies
deterministically at any time of day.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sort the bandwidth import into the correct alphabetical position within
the exlab_wizard.sync.* import block and drop the unused handle variable
in test_drive_job_bandwidth_comes_from_nas_block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ansport UI

Phase 8 removed the per-equipment SFTP/SMB transport fields from both the
Add-Equipment wizard and the Settings equipment form; the NAS connection is
now the single nas: remote. These e2e tests still referenced the deleted UI,
causing TypeErrors and missing-testid failures.

- _test_app.py: drop transport_type/sftp_* kwargs from the /wizard/equipment
  route's seeded EquipmentWizardState; seed sync_mode="nas" instead. This was
  the source of the EquipmentWizardState.__init__() TypeError that broke
  test_flow_16/22.
- page_objects/wizard_equipment_page.py: remove the transport_type/sftp_*/smb_*
  locator properties for deleted UI; add nas_note/stage_note for the new
  sync-mode step.
- test_flow_26_equipment_wizard_persist.py: step 3 now waits on the nas-mode
  note instead of filling removed SFTP fields.
- test_flow_00_full_lifecycle.py: Phase 4 creates two nas-mode devices via the
  no-transport Settings form; drop the rclone_smb radio assertion and the now
  unused _pick_radio helper.
- ux_catalog.py: remove the 7 removed-UI entries (settings-equipment-transport
  + 3 sftp + 3 smb); docs/UX_INTERACTIONS.md regenerated.

tests/e2e: 64 passed, 3 skipped, 0 failures/errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…NAS sync

Add docs/setup/rclone-remote-setup.md: operator walkthrough for creating SFTP
and SMB rclone remotes, wiring nas.remote/base_root into config.yaml, performance
and modtime-tolerance tuning, encrypted-config and tray-user caveats, and
stage-mode staging_remote setup.

Update design spec sections to reflect the rclone.conf named-remote model:
- §09: replace per-equipment transport: blocks with top-level nas: block
  (remote, base_root, rclone_config_path, mtime_tolerance_s, perf, bandwidth);
  update orchestrator: to staging_remote/staging_base_root; drop nas keyring refs;
  add missing sync: fields (quiescence_minutes, poll_interval_seconds, ignore_globs).
- §07 §7.1.1: single RcloneDriver replaces rclone+rsync dual-driver model.
  §7.1.3: document the four RcloneDriver ops and named-remote target composition.
  §7.1.4: replace stale sha256-walk verify description with two-tier model
  (lsjson size+mtime reconcile post-push; rclone check --download hash gate
  at cleanup only). §7.1.7: bandwidth is global on nas: block, not per-equipment.
  §7.1.8: NASSync manages no credentials. §7.4.1: remove stale nas keyring row.
- §04 §4.9.1: add INCOMPLETE_NO_NAS_REMOTE state + configure_rclone_remote
  next_action. §4.9.3: add configure_rclone_remote to next_action list.
  §4.9.5 step 2: update equipment setup description. Fix two stale
  test-equipment API endpoint rows.
- Aggregated ExLab-Wizard_Design_Spec.md: update §7 and §9 blurbs.
- ExLab-Wizard_Frontend_Spec.md: remove per-equipment NAS HTTP-basic password
  references; update equipment sub-dialog Transport group to named-remote model.

Update README.md: add NAS sync setup section pointing at the new guide.

Update tests/docker: rewrite README.md to describe rclone.conf-based approach
(remove build_rclone_env/RCLONE_CONFIG_* env-injection prose); update
rclone.conf and rclone.conf.example comments; fix docker-compose.yml header comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
§07 §7.1.1: the component diagram listed "- rclone / - rsync-over-ssh" as
Transport Drivers and the prose said "the rclone or rsync transport drivers" —
contradicting the corrected "sole transport driver is RcloneDriver" line and
the code (sync/transports/rclone.py is the only driver). Update the diagram to
show RcloneDriver's actual ops (copy/lsjson/check/about/listremotes) and reword
the subprocess-lifetime line to reference only the rclone driver.

§07 §7.1.4: "No temporary files are written to disk" was literally false —
RcloneDriver.check writes the --combined output and the --files-from list as
small tempfiles (immediately unlinked). Reword to clarify no *data* is staged;
only two small, immediately-cleaned-up tempfiles touch disk.

§04 §4.9.1: add the pre-existing-omitted INCOMPLETE_NO_ORCHESTRATOR setup state
(enums.py:151), in evaluator order between MISSING_PATHS and NO_EQUIPMENT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Quality-only cleanup of the rclone.conf NAS-sync migration. No behavior
change: same rclone targets, same driver perf dials, same setup-state
output; public method signatures the tests call are preserved.

- nas_client: collapse the three duplicated ``sync_mode`` branches
  (_target_for_equipment / _base_root_for_equipment / _driver_for_equipment
  + the lsjson strip_prefix) behind one ``_resolve_remote`` returning a
  ``_RemoteResolution(remote, base_root, perf)``. Drop the now-redundant
  _base_root_for_equipment. Rename _build_target_for_run to _remote_subpath
  so the run-relative path composition is shared by both the target string
  and the lsjson strip_prefix instead of being open-coded twice.
- rclone: extract _checkers_flags() so push/check/lsjson forward the
  --checkers dial through one helper (argv order unchanged).
- paths: convert setup_state_missing's exhaustive if-chain to a match,
  dropping the unreachable trailing ``return []`` Pyright flagged.
- tray/dependencies: correct the stale _build_nas_sync docstring that still
  claimed keyring resolves per-equipment NAS passwords (migration removed
  credential injection; the param is accepted for compat only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The simplify pass converted setup_state_missing to an exhaustive match
over SetupState but dropped the trailing `return []`, so an unrecognized
state fell through to an implicit `None` -- breaking
test_setup_state_missing_unrecognized_state_returns_empty and violating
the function's `list[dict[str, str]]` contract. Restore the defensive
fallback (mypy warn_unreachable is off, so the type checker stays clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI was red on three counts after the rclone.conf NAS-sync migration:
- `ruff format --check` flagged 16 unformatted test files -> reformatted
  (ruff 0.15.15, matching CI).
- coverage fell to 89.11% (gate is 91%) because the new code was
  under-tested. Add real unit tests covering:
  * sync/manifest.py edge cases (unparseable modtime, non-list JSON,
    empty path, non-numeric Size) -> 100%
  * sync/transports/rclone.py error paths (missing binary, auth failure
    with no combined output, unreadable combined file, malformed about
    JSON, UNKNOWN classification) -> 100%
  * ui/mount.py module-level helpers (NAS test-connection, operations /
    session dialogs, file-context + keep-local, run wizard, staging
    state) -> ~82%
  * ui/pages/settings.py NAS-remote section + save/quit/equipment/chip
    handlers -> ~97%

Total coverage 89.11% -> 91.68%; 2258 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Xander-git
Xander-git merged commit 7d4e04f into main May 29, 2026
8 checks passed
@Xander-git
Xander-git deleted the fix/debug-with-playwright branch May 29, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants