Files
ComfyUI-Lora-Manager/docs/plans/issue-1108-scoped-scan.md
T
Will Miao f4d0228d6b feat(scanner): report and re-admit model roots that are unavailable (#1108)
`Config._dedupe_existing_paths()` drops roots whose directory does not exist
when the root list is built, so a drive that is switched off while LM starts was
not "an offline root" — it was not a root at all. The Refresh ▾ menu had no row
for it in either state, a full refresh reported nothing (its cached entries
survived only because they fall outside every configured prefix), and plugging
the drive back in changed nothing until the next restart.

* `Config` now records the configured (existence-unfiltered) paths per model
  type — from ComfyUI's list in plugin mode, from the active library snapshot in
  standalone mode, where the host mock filters non-existent paths itself — and
  exposes them through `configured_roots_for()`.
* `ModelScanner.describe_model_roots()` appends configured-but-unavailable roots
  with a live `reachable`, an `available` flag and their cached entry count, so
  `/roots` (and therefore the menu) offers a row in both states: greyed while the
  directory is missing, normal and clickable once it is back.
* `_reconcile_cache()` reports them as `skipped_roots` / `unavailable_paths` with
  reason `root_unavailable` and counts their cached entries in `kept_unreachable`,
  which is what makes the "N models kept" toast appear in the startup-offline
  case. Report-only: they stay outside the scan scope, so nothing about pruning
  changes. A root-scoped scan stays quiet about roots it was not asked about, and
  a folder-scoped scan only mentions a missing root that holds cached entries of
  that folder.
* `Config.admit_configured_roots()` re-runs the per-type prepare helpers against
  the configured paths and APPENDS what is readable now (plus the
  checkpoint/unet/other side maps), then refreshes the preview allowlist, so a
  drive plugged in mid-session can be scanned without restarting. Append-only and
  order-preserving by design: removing a root mid-session would let a later
  settings save persist the loss, and re-sorting would move `*_roots[0]`, which
  derives the recipes directory and the usage-stats file location. `_roots[0]`
  never moves.
* `/roots` and `/scan` admit first, so `describe_model_roots()` stays a pure read
  and a caller that never opens the menu (the browser extension) still gets a
  drive that came back.

Verified on the sandbox with drive-Z switched off before startup: `/roots`
reports `drive-Z reachable=false available=false models=60`, a full refresh
returns `skipped_roots=[drive-Z root_unavailable]`,
`unavailable_paths=[{... kept: 60}]`, `kept_unreachable=60`, and all 432 models
stay cached. Renaming the drive back while the server runs admits it
(`available=true`) and `GET /scan?roots=<drive-Z>` walks it with no restart.
3714 passed, 7 skipped; frontend 1495 passed (149 files); vue widgets 96 passed.
2026-10-07 16:38:38 +08:00

25 KiB
Raw Blame History

Plan: Scoped Scan (one root / one folder at a time)

Issue: #1108 — scan a single folder/root instead of the whole library. Status: P1, P2 and Wave 6 implemented (2026-10-07). P1 shipped in 470d85cc (translations in bd184559), P2 in 12930ce7 (translations in 0941f992). Supersedes the earlier draft's per-root status panel and persisted unreachable-subtree state (both dropped, see "Must NOT have").

Implementation notes / deviations from the draft below:

  • The scope dataclass is public (ReconcileScope) because it crosses the scanner → service → route boundary; the draft called it _ReconcileScope.
  • _reconcile_cache() now returns a summary dict, exposed as ModelScanner.last_reconcile_summary and returned by BaseModelService.scan_models(), so the scan HTTP response carries the counts (the WS completed payload carries the same fields).
  • Root labels are computed over the whole configured set and passed into the walk tracker, so the progress line and the menu never disagree.
  • Discovery: Config._dedupe_existing_paths() drops roots that do not exist at config load, so a drive that is already off when LM starts is not in get_model_roots() at all. Two consequences: it cannot appear in the refresh menu (nothing to select), and its cached entries are kept because they are outside every configured root prefix. The root_unreachable path therefore covers the mid-session case (drive switched off while LM runs), which is the workflow that motivated the issue; both paths keep the entries, which is what matters.
  • Known limitation to keep in mind: after a scoped scan (or one with unreadable paths) the recorded empty-folder list is unioned instead of replaced, so an empty folder deleted on disk can linger in the sidebar until the next full refresh. Models are unaffected.

TL;DR (For humans)

What you'll get: the Refresh ▾ menu can scan one model root (one drive) and leave every other root untouched. Refreshing while a drive is switched off no longer deletes that drive's models from the cache/DB — they are kept and reported. Browsing the grid while a drive is off no longer silently strips preview_url from that drive's models.

Why this approach: a scan scope is a path scope, not a device scope, so it also works for layouts where one root contains symlinks to other drives. The safety net collapses into one rule — anything this walk could not read is left untouched — and that rule is nearly free: the root existence check already exists, os.walk(onerror=...) only fires on failure, and the cached-entry prefix attribution is already computed for the walk-progress weights.

What it will NOT do: no active probing of nested symlinks; no persisted "unreachable subtree" table; no per-card offline badges (separate change); no root enable/disable switch; the main Refresh button keeps meaning "scan everything"; no configuration-convention/documentation push to re-layout libraries that use cross-drive symlinks (dropped by request).

Effort: Medium (~1.5–2 days for P1 including tests) Risk: Medium-low — the risk sits in the folder-tree (all_folders) merge under a scoped scan and in the exact pruning predicate; both get explicit tests.

Decisions to sanity-check:

  1. A full Refresh now treats a configured but unreachable root as "keep + report" instead of "everything under it was deleted". This is a behaviour change (release-note it).
  2. Keep the near-zero-cost first-level symlink reachability check (reuses config._path_mappings); nested symlinks stay uncovered.
  3. Root labels become set-aware (G: loras / usb/loras, auto-deduped by parent segments). This also changes the walk-progress line.

Scope

Must have (P1)

  • Scope-aware reconcile: _reconcile_cache(scope=...) where scope is {roots: [path] | None, folder: rel | None}. Files inside the scope reconcile normally (add / repair / remove); everything outside is neither re-read nor removed.
  • Path-level pruning guard: cached entries under a path this walk could not read are excluded from missing and reported. Three sources:
    • configured root that fails the reachability check (already filtered at model_scanner.py:1468),
    • directories os.walk failed to enter (onerror collector; covers Windows junctions to an offline drive, permission errors, I/O errors),
    • first-level symlink mappings whose target is not a directory (config._path_mappings).
  • Folder tree correctness under scope: all_folders becomes (old outside scope) ∪ (old under unreachable paths) ∪ (discovered), so a scoped scan cannot collapse the sidebar tree and an offline root keeps its folders.
  • Result payload for the toast: added, removed, repaired, scanned_roots (labels), skipped_roots ({path, label, kept}), unavailable_paths ({path, kept}, capped), kept_unreachable.
  • API: GET /api/lm/{prefix}/scan?full_rebuild=false&roots=<path> (repeatable); GET /api/lm/{prefix}/roots gains root_details: [{path, label, reachable, models}] while keeping roots: [str] unchanged (other callers depend on it).
  • Frontend: Refresh ▾ menu lists the current page's roots (label + model count, offline rows greyed out and non-clickable), main button unchanged, root items disabled while a scan runs; scoped completion toast reports counts and any kept/skipped paths.
  • Preview fix: a preview 404 must not clear preview_url when the file's parent directory is itself unreachable (preview_handlers.py:57 + _cleanup_stale_preview_url).
  • Set-aware root labels (_root_display_labels(roots)), shared by the progress broadcasts and /roots: last path segment, Windows drive prefix (G: loras), deduped by prepending real parent segments (usb/loras, a/models/loras), (2) fallback by sorted path, 40-char cap, full path in the tooltip.
  • i18n keys in locales/en.json + python scripts/sync_translation_keys.py.
  • pytest + vitest coverage for every item above.

Must have (P2, implemented)

  • folder=<rel> parameter: walk <root>/<rel> for every reachable root that contains it (the sidebar's unified tree has no root identity, so "this folder" means "this relative path in all roots"), prune only inside that prefix. Validation reuses normalize_relative_folder() (extracted from ModelMoveService so the folder operations and the scan endpoint reject the same input: absolute paths, drive letters, .. climbing). The summary carries scope_label = the folder, so the toast names the folder the user clicked rather than the roots it happens to live under.
  • Sidebar folder context menu entry "Scan this folder" (templates/components/context_menu.html #sidebarFolderContextMenu, above check-folder-updates; update tests/frontend/regression/sidebarFolderContextMenu.test.js expectations). The entry is gated like the other folder operations and resolves the folder through the existing _resolveFolderCandidates() before scanning, so a folder no root holds any more explains itself instead of scanning nothing. It shares check-folder-updates's divider (both are refresh-ish).

Must NOT have (guardrails)

  • NO per-entry symlink probing during the walk — DirEntry.is_symlink() costs a syscall per entry on Windows, the exact cost the reconcile optimisation removed. Nested symlinks stay uncovered.
  • NO persisted unavailable_paths table, no DB schema change, no new cache state.
  • NO configuration-convention guidance for cross-drive symlinks (dropped by request).
  • NO per-card/modal offline badges in this plan (separate change; needs the L1 prefix list which P1 produces, so it can build on this later).
  • NO root enable/disable setting, NO status panel in Settings.
  • NO change to full_rebuild=true (it still walks everything and replaces the cache); roots + full_rebuild=true is rejected with 400.
  • NO new dependency, NO change to the extension-facing endpoints.
  • NO os.path.realpath for scope/prune routing — business paths only (AGENTS.md rule).

Todos

Wave 1 — backend semantics and safety net

  1. Scope plumbing in the scanner. What to do: add _ReconcileScope (dataclass: roots: Optional[List[str]], folder: Optional[str]) and _reconcile_cache(scope=None). Resolve the effective root list once: configured roots ∩ scope.roots, filtered by reachability; missing ones become skipped_roots (never pruned). _walk_roots_for_reconcile / _walk_root_group_sync / _walk_root_for_reconcile take the scope so the walk can start at <root>/<folder> while still computing folder/file_path relative to the root (_process_model_file(path, root_path) must keep receiving the root). References: py/services/model_scanner.py:1453 (_reconcile_cache), :1468 (root filter), :1832 (_walk_root_group_sync), :365 (_walk_root_for_reconcile), :134 (_count_cached_entries_per_root), :126 (_normalized_root_prefix). Done when: a scoped reconcile over 2 roots leaves the other root's raw_data, hash index and folder list byte-identical.

  2. Pruning predicate + unreachable collection. What to do: missing = {p for p in cached_paths - found_paths if in_scope(p) and not under_unreachable(p)}; collect unreachable prefixes from (a) skipped roots, (b) an onerror callback on os.walk (record oserror.filename as a business path), (c) config._path_mappings entries whose target fails os.path.isdir (mapping is keyed target→link; protect the link prefix). Count the kept entries per prefix for reporting. References: py/services/model_scanner.py:1679 (missing_files = cached_paths - found_paths), :398 (os.walk(..., followlinks=True)), py/config.py:167 (_path_mappings), :801 (add_path_mapping, target→link), :750-775 (first-level-only symlink scan). Done when: with a root removed from disk (or a directory replaced by an unreadable junction), raw_data keeps those entries, removed is 0, and the counts land in the result payload.

  3. Folder-tree merge under scope. What to do: replace the unconditional sorted_discovered assignment with the union rule from "Must have"; keep the folders_changed comparison and the persist path unchanged. Unscoped scans with nothing unreachable must produce exactly today's list. References: py/services/model_scanner.py (walk loop records discovered_folders; model_scanner.py:1738 (sorted_discovered) and :1739 (folders_changed) after the dedup pass). Done when: a scoped scan keeps folders outside the scope, an offline root keeps its subtree, and a full scan still drops folders deleted on disk.

  4. Result payload + progress/cancel/complete messages. What to do: extend the completed broadcast with scanned_roots, skipped_roots, unavailable_paths, kept_unreachable, repaired; the walk tracker already receives only the scoped roots, so the progress line shows that root's label with roots_total=1. References: py/services/model_scanner.py _broadcast_scan_progress (:576), _ReconcileWalkTracker (:266), the completed broadcast at the end of _reconcile_cache. Done when: a scoped scan emits stage=reconcile_scan with the single scoped label and a completed payload carrying the new fields.

  5. API surface. What to do: scan_models accepts repeated roots query params (400 on unknown root, 400 when combined with full_rebuild=true); get_model_roots adds root_details (label, reachable, model count via the shared prefix attribution) without touching roots. References: py/routes/handlers/model_handlers.py:1139 (scan_models), :1165 (get_model_roots), py/routes/model_route_registrar.py:61-62, static/js/api/apiConfig.js:114. Done when: tests/routes/test_lora_routes.py-style coverage passes for the new params and the backward-compatible /roots payload.

  6. Preview 404 must not prune when the parent is unreachable. What to do: in serve_preview, before _cleanup_stale_preview_url, check os.path.isdir(os.path.dirname(resolved)); if the directory itself is missing/unreachable, return 404 without clearing caches. (Centralized sidecar mode puts the preview in the mirror tree, so the check must use the preview file's own parent.) References: py/routes/handlers/preview_handlers.py:57 (404 branch), :74-110 (_cleanup_stale_preview_url → clear_preview_by_path + _persist_current_cache), tests/routes/test_preview_routes.py. Done when: a test asserts the cache keeps preview_url when the parent dir is absent and still clears it when the file was really deleted from a reachable directory.

Wave 2 — labels

  1. _root_display_labels(roots) (set-aware, order-independent). What to do: replace the single-root _root_display_label; compute over the sorted root list; prepend real parent segments until unique; (2)/(3) fallback; 40-char middle-ellipsis; return {path: label}. Use it in _ReconcileWalkTracker.__init__ (progress) and in the /roots handler so both always agree. References: py/services/model_scanner.py:117 (_root_display_label, current single-root version), :266 (_ReconcileWalkTracker). Done when: unit tests cover usb/loras + ssd/loras, a/models/loras + b/models/loras, Windows G:\x\loras + H:\y\loras → G: loras / H: loras, and the identical-path fallback.

Wave 3 — frontend

  1. Refresh ▾ scope section. What to do: add an empty container to templates/components/controls.html inside .dropdown-menu (#refreshScopeMenu) + a section title; on dropdown open, fetch endpoints.roots, render one row per root (data-action="scan-root", data-root="<path>", label + model count, disabled + "Offline" for unreachable), hide the section on the recipes page (recipes have no roots); wire clicks by event delegation on the menu (the existing full-rebuild item is bound with a direct querySelector, dynamic rows cannot be); invalidate the cached list after a scan finishes; add CSS for the section + max-height/ overflow-y so 4+ roots stay usable. References: templates/components/controls.html:67-76, static/js/components/controls/PageControls.js:131 (data-action="refresh"), :220-260 (initDropdowns + full-rebuild wiring), static/js/api/apiConfig.js:118 (roots endpoint), static/js/api/baseModelApi.js:1318 (fetchModelRoots). Done when: the menu lists roots with counts, an offline root is visibly disabled, and clicking a row starts a scoped refresh.

  2. Scoped request + toast. What to do: refreshModels(fullRebuild, {roots}) appends repeated roots params; the completed payload drives the toast (added/removed/kept_unreachable/skipped_roots); offline rows toast … is not reachable right now. Nothing was changed. without a request. References: static/js/api/baseModelApi.js:514-602 (refreshModels, URL build at :601, toast at :618), static/js/components/controls/PageControls.js:485. Done when: the frontend test asserts the scoped URL and both toast variants.

  3. i18n. What to do: add loras.controls.refresh.scopeSection, .rootOffline, .rootModels, toast.api.refreshCompleteScoped, toast.api.refreshKeptUnreachable, toast.api.scanRootUnreachable to locales/en.json, run python scripts/sync_translation_keys.py, then stop (placeholders are the expected state until translations are requested). Done when: pytest tests/i18n passes and every locale has the keys.

Wave 4 — tests and verification

  1. Backend tests (tests/services/test_model_scanner.py, tests/routes/test_lora_routes.py, tests/routes/test_preview_routes.py): test_reconcile_scoped_scan_leaves_other_roots_untouched, test_reconcile_scoped_scan_removes_deleted_files_in_scope, test_reconcile_scoped_scan_preserves_folder_tree_outside_scope, test_reconcile_keeps_entries_of_unreachable_root, test_reconcile_keeps_entries_under_unreadable_dir, test_reconcile_keeps_entries_under_offline_first_level_symlink, test_root_display_labels_dedupe_by_parent_segments, test_scan_models_accepts_roots_param, test_scan_rejects_unknown_root, test_scan_rejects_roots_with_full_rebuild, test_roots_endpoint_reports_details, test_preview_404_keeps_cache_when_parent_dir_missing.

  2. Frontend tests: extend tests/frontend/api/baseModelApi.refresh.test.js (scoped URL, both toasts) and add tests/frontend/components/controls/pageControls.scanRoots.test.js (rows rendered from /roots, offline row disabled, delegation wiring).

  3. Full suites + sandbox eyeball: pytest -q, npx vitest run, npm run test:vue; then a sandboxed standalone instance with two roots and a slowed walk (LM_WALK_DELAY_S-style sitecustomize hook) to eyeball the scoped progress line, the offline row and the toast — the user verifies by eye.

Wave 5 — P2 (folder scope) — done

  1. folder=<rel> in the scan endpoint + ReconcileScope.folder, the sidebar menu entry, the context-menu regression test update, and the "folder in several roots" semantics (scan every reachable root that has the relative path; report per root).
    • normalize_relative_folder() extracted to module scope in model_file_service.py (the private static method now delegates) and reused by the scan handler.
    • scanFolder() in SidebarManager reuses _resolveFolderCandidatesSafe() and delegates to the host page controls, which pass { folder } through registerAPI's argument-forwarding refreshModels.
    • Verified live: folder=pack000 on the three-root sandbox walks drive-G and drive-Y, reports scope_label=pack000, keeps drive-Z's 6 entries under that folder (kept_unreachable=6) and leaves all 420 models cached.

Wave 6 — configured vs currently available roots (approved follow-up)

The P1/P2 work exposed an asymmetry: Config._dedupe_existing_paths() drops roots whose directory does not exist at the moment the root list is built (startup, or applying a library snapshot), so a drive that is switched off while LM starts is not "an offline root" — it is not a root at all. Consequences, all verified on the sandbox:

  • /roots does not list it, so the Refresh ▾ menu shows no row for it in either state;
  • a full refresh reports nothing (kept_unreachable: 0, no toast) even though its cached entries are kept — they survive only because they fall outside every configured root prefix;
  • plugging the drive back in does not bring the row back: the in-memory list is not re-validated;
  • plugin mode only: Config.save_folder_paths_to_settings() (called from Config.__init__) persists target_folder_paths["loras"] = list(self.loras_roots) through upsert_library(folder_paths=...), which replaces the library's paths. Starting ComfyUI with a drive switched off therefore erases that path from settings.json — a configuration loss, not just a display gap. Extra paths are unaffected (that call reuses the stored extra_folder_paths), and the same pattern applies to checkpoints/unet/embeddings/other-model primary paths.

Must have

  1. Never erase an unavailable root from the library config. Config records the configured (existence-unfiltered) primary paths per model type while building the live lists, and save_folder_paths_to_settings() persists those instead of the filtered ones. _resolve_valid_default_root() receives the configured paths in allowed_paths too, so a default_*_root that sits on a switched-off drive is not "repaired" away.
  2. Report configured-but-unavailable roots. Config.configured_roots_for(model_type) feeds ModelScanner.describe_model_roots(), which appends them with available: false, a live reachable and their cached entry count — so the Refresh ▾ menu has a row in both states (greyed while the directory is missing, normal and clickable once it is back). The reconcile summary reports them as skipped_roots / unavailable_paths with reason root_unavailable and counts their cached entries in kept_unreachable, which is what makes the "N models kept" toast appear in the startup-offline case.
  3. Admit them again, append-only. Config.admit_configured_roots() re-runs the per-type prepare helpers against the configured paths and appends what exists now (plus the checkpoint/unet/other side maps) to the live lists, then refreshes the preview allowlist. Called from /roots and /scan, so a drive plugged in mid-session can be scanned without restarting. Appending (never re-sorting) keeps loras_roots[0] — which derives the recipes directory and the usage-stats file location — stable for the whole session.

Must NOT have

  • NO removal of a root from the live lists mid-session (that is what would let a later settings save persist a reduced configuration, and it would move *_roots[0]).
  • NO change to the scan/prune scope: a root that is still unavailable stays out of scope, so its entries remain "kept because out of scope" exactly as today.
  • NO persisted unavailable-state, no DB schema change, no per-entry filesystem probe.

Where "configured" comes from

Config records the existence-unfiltered paths while building the live lists, but the source differs per mode and that matters:

  • Plugin mode: folder_paths.get_folder_paths() is ComfyUI's own list, unfiltered — a path the user removed there must be forgotten, one that is merely missing must be kept. The host list wins.
  • Standalone mode: standalone.MockFolderPaths.get_folder_paths() already filters os.path.exists out of settings.json, so the host list cannot answer "what did the user configure". The active library snapshot (libraries[<active>].folder_paths, falling back to the top-level folder_paths) is the record, and it wins there. (_remember_library_configured_paths() implements the split.)

Verification

  • Config: a configured path that does not exist is still written back by save_folder_paths_to_settings(); a path the host no longer configures is still dropped.
  • Promotion: create the directory after the lists were built → admit_configured_roots() adds it at the end, keeps every existing root in place, and adds nothing twice.
  • Scanner: describe_model_roots() reports the missing root (available: false, cached count); a full refresh lists it in skipped_roots/unavailable_paths and keeps its entries; /scan with that root works once the directory is back.
  • Sandbox, drive-Z gone before startup: /roots reports drive-Z with reachable=false available=false models=60; a full refresh returns skipped_roots=[drive-Z root_unavailable], unavailable_paths=[{... kept: 60}], kept_unreachable=60 and leaves all 432 models cached. (Both measured; before this wave the menu had no row and the refresh reported nothing.)
  • Sandbox, drive renamed back while the server runs: /roots admits it (available=true reachable=true) and GET /scan?roots=<drive-Z> walks it (scanned_roots=['drive-Z'], 0 added / 0 removed) — no restart required. (Measured.)

Known limitations (accepted)

  • Nested symlinks (a symlink below a root pointing at another drive) are only covered when os.walk fails to enter the target (Windows junctions, permission errors). A broken symlink that os.walk classifies as a non-directory (typical on POSIX) is not detected: its entries are pruned as today and come back on the next scan once the target is reachable (sidecars carry the sha256, so no re-hash). Verify the Windows junction behaviour before writing release notes.
  • Only first-level symlinks are known to config (by design, config.py:750-775), so the symlink reachability check inherits that limit.
  • A scoped scan of a folder does not re-read metadata for unchanged files (unchanged behaviour).
  • Empty folders removed on disk can linger in the sidebar after a scoped scan (the folder list is unioned rather than replaced whenever the scan did not verify every root); the next full refresh drops them.
  • root_details[].reachable is a live os.path.exists() per root, so a root that disappears mid-session shows as offline; a root that was already gone at startup is filtered out by Config and therefore absent from the list.

Verification checklist

  • pytest -q green (3704 passed, 7 skipped), npx vitest run green (1495 passed), npm run test:vue green (96 passed).
  • python scripts/sync_translation_keys.py --dry-run reports no pending changes.
  • Sandbox: 3 roots, one offline → scoped scan of one root walks only it; a full refresh keeps the offline root's models (kept_unreachable=60) and the grid still shows all 420.
  • Sandbox: folder scope (folder=pack000) walks the two reachable roots, labels the scan by the folder and keeps the offline root's 6 entries under it.
  • Sandbox: preview of an offline root's model returns 404 and the DB keeps preview_url (test-locked; not eyeballed in the sandbox because the demo models have no previews).
  • Release note wording agreed for the behaviour change (decision 1).
  • The 2 new sidebar keys (sidebar.scanFolder, sidebar.scanFolderResult.missing) are [TODO: Translate] placeholders pending the feature owner's go-ahead.