The Refresh menu can scope a scan to one model root, but the folder a user is
looking at lives in the sidebar's unified tree, which merges every root into one
relative-path namespace. "Scan this folder" therefore addresses the folder, not a
root: the backend walks that relative path under every root that holds it, which
is also what makes the action safe while another drive is switched off.
Backend:
* GET /scan accepts `folder=<rel>` (alone or with `roots=`), rejected together
with full_rebuild=true like the roots parameter. Validation reuses
normalize_relative_folder(), extracted to module scope from ModelMoveService so
the folder operations and the scan endpoint reject the same input (absolute
paths, drive letters, `..` climbing) instead of each carrying its own copy.
* The reconcile summary carries `scope_label` (the folder) for a folder scope, so
the result toast names the folder the user clicked instead of the roots it
happens to live under; the completed WS payload carries it too.
* `folder` is a scope prefix exactly like a root: only that subtree is re-read or
pruned, and an unreachable root keeps the entries that fall inside it.
Frontend:
* The sidebar folder context menu gains "Scan this folder" above "Check for
updates in this folder" (they share the refresh divider); the entry is gated by
the same supportsFolderManagement flag as the other folder operations.
* SidebarManager.scanFolder() resolves the node through the existing
_resolveFolderCandidates() before doing anything: a folder no root holds any
more explains itself ("no longer exists on disk") instead of scanning nothing,
and an unresolvable multi-root node is refused rather than guessed.
* PageControls.refreshModels() and BaseModelApi.refreshModels() forward the folder
scope, and _showRefreshSummary() prefers scope_label over the walked roots.
Verified live on the three-root sandbox with one drive switched off:
GET /scan?folder=pack000 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. 3704 passed, 7 skipped; frontend 1495 passed (150 files); vue
widgets 96 passed. The 2 new sidebar keys are [TODO: Translate] placeholders
pending the feature owner's go-ahead.
20 KiB
Plan: Scoped Scan (one root / one folder at a time)
Issue: #1108 — scan a single
folder/root instead of the whole library.
Status: P1 and P2 implemented (2026-10-07). P1 shipped in 470d85cc (translations in
bd184559); P2 (folder scope + the sidebar entry) is in the working tree. 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 asModelScanner.last_reconcile_summaryand returned byBaseModelService.scan_models(), so the scan HTTP response carries the counts (the WScompletedpayload 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 inget_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. Theroot_unreachablepath 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:
- 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).
- Keep the near-zero-cost first-level symlink reachability check (reuses
config._path_mappings); nested symlinks stay uncovered. - 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
missingand reported. Three sources:- configured root that fails the reachability check (already filtered at
model_scanner.py:1468), - directories
os.walkfailed to enter (onerrorcollector; 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).
- configured root that fails the reachability check (already filtered at
- Folder tree correctness under scope:
all_foldersbecomes(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}/rootsgainsroot_details: [{path, label, reachable, models}]while keepingroots: [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_urlwhen 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 reusesnormalize_relative_folder()(extracted fromModelMoveServiceso the folder operations and the scan endpoint reject the same input: absolute paths, drive letters,..climbing). The summary carriesscope_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, abovecheck-folder-updates; updatetests/frontend/regression/sidebarFolderContextMenu.test.jsexpectations). 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 sharescheck-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_pathstable, 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=trueis rejected with 400. - NO new dependency, NO change to the extension-facing endpoints.
- NO
os.path.realpathfor scope/prune routing — business paths only (AGENTS.md rule).
Todos
Wave 1 — backend semantics and safety net
-
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 becomeskipped_roots(never pruned)._walk_roots_for_reconcile/_walk_root_group_sync/_walk_root_for_reconciletake the scope so the walk can start at<root>/<folder>while still computingfolder/file_pathrelative 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'sraw_data, hash index and folder list byte-identical. -
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) anonerrorcallback onos.walk(recordoserror.filenameas a business path), (c)config._path_mappingsentries whose target failsos.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_datakeeps those entries,removedis 0, and the counts land in the result payload. -
Folder-tree merge under scope. What to do: replace the unconditional
sorted_discoveredassignment with the union rule from "Must have"; keep thefolders_changedcomparison and the persist path unchanged. Unscoped scans with nothing unreachable must produce exactly today's list. References:py/services/model_scanner.py(walk loop recordsdiscovered_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. -
Result payload + progress/cancel/complete messages. What to do: extend the
completedbroadcast withscanned_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 withroots_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 emitsstage=reconcile_scanwith the single scoped label and acompletedpayload carrying the new fields. -
API surface. What to do:
scan_modelsaccepts repeatedrootsquery params (400 on unknown root, 400 when combined withfull_rebuild=true);get_model_rootsaddsroot_details(label, reachable, model count via the shared prefix attribution) without touchingroots. 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/rootspayload. -
Preview 404 must not prune when the parent is unreachable. What to do: in
serve_preview, before_cleanup_stale_preview_url, checkos.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 keepspreview_urlwhen the parent dir is absent and still clears it when the file was really deleted from a reachable directory.
Wave 2 — labels
_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/rootshandler so both always agree. References:py/services/model_scanner.py:117(_root_display_label, current single-root version),:266(_ReconcileWalkTracker). Done when: unit tests coverusb/loras+ssd/loras,a/models/loras+b/models/loras, WindowsG:\x\loras+H:\y\loras→G: loras/H: loras, and the identical-path fallback.
Wave 3 — frontend
-
Refresh ▾ scope section. What to do: add an empty container to
templates/components/controls.htmlinside.dropdown-menu(#refreshScopeMenu) + a section title; on dropdown open, fetchendpoints.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 existingfull-rebuilditem is bound with a directquerySelector, dynamic rows cannot be); invalidate the cached list after a scan finishes; add CSS for the section +max-height/overflow-yso 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-rebuildwiring),static/js/api/apiConfig.js:118(rootsendpoint),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. -
Scoped request + toast. What to do:
refreshModels(fullRebuild, {roots})appends repeatedrootsparams; thecompletedpayload 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. -
i18n. What to do: add
loras.controls.refresh.scopeSection,.rootOffline,.rootModels,toast.api.refreshCompleteScoped,toast.api.refreshKeptUnreachable,toast.api.scanRootUnreachabletolocales/en.json, runpython scripts/sync_translation_keys.py, then stop (placeholders are the expected state until translations are requested). Done when:pytest tests/i18npasses and every locale has the keys.
Wave 4 — tests and verification
-
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. -
Frontend tests: extend
tests/frontend/api/baseModelApi.refresh.test.js(scoped URL, both toasts) and addtests/frontend/components/controls/pageControls.scanRoots.test.js(rows rendered from/roots, offline row disabled, delegation wiring). -
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-stylesitecustomizehook) to eyeball the scoped progress line, the offline row and the toast — the user verifies by eye.
Wave 5 — P2 (folder scope) — done
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 inmodel_file_service.py(the private static method now delegates) and reused by the scan handler.scanFolder()inSidebarManagerreuses_resolveFolderCandidatesSafe()and delegates to the host page controls, which pass{ folder }throughregisterAPI's argument-forwardingrefreshModels.- Verified live:
folder=pack000on the three-root sandbox walks drive-G and drive-Y, reportsscope_label=pack000, keeps drive-Z's 6 entries under that folder (kept_unreachable=6) and leaves all 420 models cached.
Known limitations (accepted)
- Nested symlinks (a symlink below a root pointing at another drive) are only covered when
os.walkfails to enter the target (Windows junctions, permission errors). A broken symlink thatos.walkclassifies 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[].reachableis a liveos.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 byConfigand therefore absent from the list.
Verification checklist
pytest -qgreen (3704 passed, 7 skipped),npx vitest rungreen (1495 passed),npm run test:vuegreen (96 passed).python scripts/sync_translation_keys.py --dry-runreports 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.