diff --git a/locales/en.json b/locales/en.json index baae5c95..998622fb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1416,6 +1416,8 @@ "missingMessage": "This folder no longer exists on disk. Refresh the sidebar and try again.", "symlinkTitle": "Folder is a symbolic link", "symlinkMessage": "Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.", + "protectedTitle": "Folder is protected", + "protectedMessage": "This folder holds the recipe library and cannot be deleted from LoRA Manager.", "busyTitle": "A deletion is still pending", "checking": "Checking the folder contents...", "confirm": "Delete folder", @@ -1458,6 +1460,7 @@ "busyStatus": "a deletion is still pending", "missingStatus": "no longer exists on disk", "symlinkStatus": "symbolic link — cannot be deleted here", + "protectedStatus": "holds the recipe library — cannot be deleted here", "unknownStatus": "could not be checked", "deletedStatus": "deleted" }, diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index 56190581..d25edbb6 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -8,6 +8,7 @@ from abc import ABC, abstractmethod from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS +from ..utils.recipes_paths import path_is_or_contains_recipes_dir from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir from ..services.settings_manager import get_settings_manager from ..services.model_lifecycle_service import _require_path_in_library_roots @@ -697,6 +698,19 @@ class ModelMoveService: "error": "The library root itself cannot be deleted", } + if path_is_or_contains_recipes_dir(absolute_path): + # The recipes dir holds no model weight files, so the + # model_count check below cannot protect it — but removing it + # wipes the user's recipe library. + return { + "success": False, + "code": "protected", + "error": ( + "This folder holds the recipe library and cannot be " + "deleted from here" + ), + } + manifest = self._collect_folder_manifest(absolute_path) if manifest["pending_delete_job"]: @@ -948,6 +962,18 @@ class ModelMoveService: "error": "The library root itself cannot be renamed", } + if path_is_or_contains_recipes_dir(absolute_path): + # Renaming the recipes dir (or an ancestor) strands the recipe + # library: the scanner keeps looking at the configured path. + return { + "success": False, + "code": "protected", + "error": ( + "This folder holds the recipe library and cannot be " + "renamed from here" + ), + } + previous_relative = self._calculate_relative_folder(absolute_path) target = os.path.join(os.path.dirname(absolute_path), new_name) diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index 623910a2..d70cd73b 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -21,6 +21,10 @@ from ..utils.sidecar_paths import ( resolve_centralized_dir_for_dir, ) from ..utils.civitai_utils import resolve_license_info +from ..utils.recipes_paths import ( + get_effective_recipes_dir, + normalized_recipes_dir_key, +) from .model_cache import ModelCache from .model_hash_index import ModelHashIndex from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots @@ -72,6 +76,11 @@ def _is_hidden_relative_path(rel_path: str) -> bool: return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/")) +def _dir_entry_key(parent: str, name: str) -> str: + """normcase+abspath key for a directory entry, for reserved-dir checks.""" + return os.path.normcase(os.path.abspath(os.path.join(parent, name))) + + def _file_name_stem(file_path: str) -> str: """Return the extension-free file name of a normalized model path. @@ -577,6 +586,9 @@ def _walk_root_for_reconcile( stale_seen: Set[str] = set() files_since_report = 0 walk_start = walk_path or root_path + # The recipe library sits inside a lora root by default; it is a reserved + # directory, never a model folder, so the walk must not descend into it. + recipes_dir_key = normalized_recipes_dir_key() def _on_walk_error(error: OSError) -> None: """Record a directory the walk could not enter (offline/denied).""" @@ -599,7 +611,11 @@ def _walk_root_for_reconcile( for root, dirnames, files in os.walk( walk_start, followlinks=True, onerror=_on_walk_error ): - dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)] + dirnames[:] = [ + d + for d in dirnames + if not _is_excluded_dir(d) and _dir_entry_key(root, d) != recipes_dir_key + ] real_root = os.path.realpath(root) if not dir_claims.claim(real_root): @@ -1451,17 +1467,20 @@ class ModelScanner: await self._sync_download_history(snapshot.raw_data, source='scan') def _count_model_files(self) -> int: """Count all model files with supported extensions in all roots - + Returns: int: Total number of model files found """ total_files = 0 visited_real_paths = set() - + # The recipe library holds no model files; skip it so the progress + # estimate matches what the scan itself will walk. + recipes_dir_key = normalized_recipes_dir_key() + for root_path in self.get_model_roots(): if not os.path.exists(root_path): continue - + def count_recursive(path): nonlocal total_files try: @@ -1469,7 +1488,7 @@ class ModelScanner: if real_path in visited_real_paths: return visited_real_paths.add(real_path) - + with os.scandir(path) as it: for entry in it: try: @@ -1480,6 +1499,8 @@ class ModelScanner: elif entry.is_dir(follow_symlinks=True): if _is_excluded_dir(entry.name): continue + if _dir_entry_key(path, entry.name) == recipes_dir_key: + continue count_recursive(entry.path) except Exception as e: logger.error(f"Error counting files in entry {entry.path}: {e}") @@ -2407,8 +2428,49 @@ class ModelScanner: else: self._schedule_all_folders_backfill() + reserved = self._recipes_folder_rel_paths() + if reserved: + folders = { + folder + for folder in folders + if not any( + folder.casefold() == rel or folder.casefold().startswith(rel + "/") + for rel in reserved + ) + } + return sorted(folders, key=lambda x: x.lower()) + def _recipes_folder_rel_paths(self) -> Set[str]: + """Casefolded library-relative names of the recipes dir, per root. + + The recipe library sits inside a lora root by default; it is a + reserved directory, never a model folder. The scan walks no longer + record it, but snapshots persisted before that exclusion still carry + it, so readers filter it out here. + """ + try: + recipes_dir = get_effective_recipes_dir() + except Exception: # pragma: no cover - defensive + return set() + if not recipes_dir: + return set() + + recipes_abs = os.path.abspath(recipes_dir) + recipes_key = os.path.normcase(recipes_abs) + reserved: Set[str] = set() + for root in self.get_model_roots(): + root_abs = os.path.abspath(root) + root_key = os.path.normcase(root_abs) + if recipes_key != root_key and not recipes_key.startswith( + root_key + os.sep + ): + continue + rel = os.path.relpath(recipes_abs, root_abs).replace(os.sep, "/") + if rel and rel != ".": + reserved.add(rel.casefold()) + return reserved + async def add_known_folder(self, folder: str) -> None: """Record a folder (and its parents) in the known folder list. @@ -2894,17 +2956,25 @@ class ModelScanner: """Enumerate every directory under the model roots, live from disk. Runs in a worker thread. Hidden directories (any segment starting - with '.') and the pending-delete staging dir are excluded. + with '.'), the pending-delete staging dir and the recipes storage + dir are excluded. """ discovered: Set[str] = set() visited_real_paths: Set[str] = set() + # The recipe library is a reserved directory (see the reconcile walk). + recipes_dir_key = normalized_recipes_dir_key() for root_path in self.get_model_roots(): if not os.path.exists(root_path): continue for root, dirnames, _files in os.walk(root_path, followlinks=True): - dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)] + dirnames[:] = [ + d + for d in dirnames + if not _is_excluded_dir(d) + and _dir_entry_key(root, d) != recipes_dir_key + ] # realpath is used only for symlink dedup, never for the # recorded path (business paths stay unresolved). real_root = os.path.realpath(root) @@ -3256,6 +3326,10 @@ class ModelScanner: processed_real_files: Set[str] = set() visited_real_dirs: Set[str] = set() discovered_folders: Set[str] = set() + # The recipe library sits inside a lora root by default; it is a + # reserved directory, never a model folder, so the scan must not + # descend into it. + recipes_dir_key = normalized_recipes_dir_key() async def handle_progress(current_name: str = '') -> None: if progress_callback is None: @@ -3334,6 +3408,11 @@ class ModelScanner: elif entry.is_dir(follow_symlinks=True): if _is_excluded_dir(entry.name): continue + if ( + _dir_entry_key(current_path, entry.name) + == recipes_dir_key + ): + continue # Record every directory (including empty ones) so # the folder tree can be served without a live walk. rel_dir = os.path.relpath( diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 9d118102..2900f043 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -2676,23 +2676,11 @@ class RecipeScanner: @property def recipes_dir(self) -> str: """Get path to recipes directory""" - from .settings_manager import get_settings_manager + from ..utils.recipes_paths import get_effective_recipes_dir - custom_recipes_dir = get_settings_manager().get("recipes_path", "") - if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip(): - recipes_dir = os.path.abspath( - os.path.normpath(os.path.expanduser(custom_recipes_dir.strip())) - ) + recipes_dir = get_effective_recipes_dir() + if recipes_dir: os.makedirs(recipes_dir, exist_ok=True) - return recipes_dir - - if not config.loras_roots: - return "" - - # config.loras_roots already sorted case-insensitively, use the first one - recipes_dir = os.path.join(config.loras_roots[0], "recipes") - os.makedirs(recipes_dir, exist_ok=True) - return recipes_dir async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache: diff --git a/py/utils/recipes_paths.py b/py/utils/recipes_paths.py new file mode 100644 index 00000000..f028d023 --- /dev/null +++ b/py/utils/recipes_paths.py @@ -0,0 +1,65 @@ +"""Resolution of the recipe library's storage directory. + +Kept side-effect free (no directory creation): model scanners and file +services consult these helpers on hot paths, where silently materializing +the folder would be a surprise. ``RecipeScanner.recipes_dir`` remains the +write endpoint and adds the ``makedirs`` itself. +""" + +from __future__ import annotations + +import os + + +def get_effective_recipes_dir() -> str: + """Resolve the directory recipe data lives in. May not exist on disk. + + Mirrors ``RecipeScanner.recipes_dir`` minus the auto-create: the + ``recipes_path`` setting wins; otherwise the default is ``recipes`` + under the first configured lora root (``config.loras_roots`` is already + sorted case-insensitively). Returns ``""`` when no lora root exists. + """ + # Local imports: services -> config -> utils dependency direction must + # not pick up a utils -> services edge at module import time. + from ..services.settings_manager import get_settings_manager + from ..config import config + + custom_recipes_dir = get_settings_manager().get("recipes_path", "") + if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip(): + return os.path.abspath( + os.path.normpath(os.path.expanduser(custom_recipes_dir.strip())) + ) + + lora_roots = [ + path + for path in (getattr(config, "loras_roots", None) or []) + if isinstance(path, str) and path.strip() + ] + if not lora_roots: + return "" + + return os.path.abspath(os.path.join(lora_roots[0], "recipes")) + + +def normalized_recipes_dir_key() -> str: + """Comparison key (normcase + abspath) for the recipes dir; "" if unknown.""" + try: + recipes_dir = get_effective_recipes_dir() + except Exception: # pragma: no cover - never break a scan over settings access + return "" + if not recipes_dir: + return "" + return os.path.normcase(os.path.abspath(recipes_dir)) + + +def path_is_or_contains_recipes_dir(path: str) -> bool: + """True when *path* is the recipes dir or one of its ancestors. + + Deleting or renaming an ancestor wipes the recipe library just the same, + so folder operations must refuse both shapes. + """ + key = normalized_recipes_dir_key() + if not key: + return False + candidate = os.path.normcase(os.path.abspath(path)) + return candidate == key or key.startswith(candidate + os.sep) diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index 09c0edff..34da5d2a 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -1301,6 +1301,8 @@ export class SidebarManager { ...this._notEmptyBlocker(error?.manifest), ...options, }); + } else if (error?.code === 'protected') { + this._renderDeleteFolderModal(relativePath, 'protected', options); } else if (error?.code === 'busy') { this._renderDeleteFolderModal(relativePath, 'busy', options); } else if (error?.code === 'missing') { @@ -1458,6 +1460,9 @@ export class SidebarManager { entry.modelCount = Number(error?.manifest?.model_count) || 0; entry.excludedCount = Number(error?.manifest?.excluded_model_count) || 0; entry.checked = false; + } else if (error?.code === 'protected') { + entry.status = 'protected'; + entry.checked = false; } else if (error?.code === 'busy') { entry.status = 'busy'; entry.checked = false; @@ -1484,7 +1489,10 @@ export class SidebarManager { nodes.checkbox.disabled = !selectable; nodes.checkbox.checked = entry.checked; nodes.status.textContent = this._deleteFolderRowStatus(entry); - nodes.status.classList.toggle('blocked', entry.status === 'not_empty'); + nodes.status.classList.toggle( + 'blocked', + entry.status === 'not_empty' || entry.status === 'protected' + ); } /** Whether a probed copy may be selected for deletion. */ @@ -1512,6 +1520,11 @@ export class SidebarManager { return translate('sidebar.folderRoot.busyStatus', {}, 'a deletion is still pending'); case 'missing': return translate('sidebar.folderRoot.missingStatus', {}, 'no longer exists on disk'); + case 'protected': + return translate( + 'sidebar.folderRoot.protectedStatus', {}, + 'holds the recipe library — cannot be deleted here' + ); case 'symlink': return translate( 'sidebar.folderRoot.symlinkStatus', {}, @@ -1738,9 +1751,10 @@ export class SidebarManager { * `state` is 'confirm' (deletion may proceed), 'blocked' (models would be * cascaded over, which the backend refuses), 'busy' (a staged delete is * still pending inside the folder), 'missing' (no root holds the node's - * directory any more) or 'symlink' (every copy is a symbolic link, which the - * backend refuses to remove). `checking` keeps the confirm button disabled - * while the authoritative server-side check runs. + * directory any more), 'symlink' (every copy is a symbolic link, which the + * backend refuses to remove) or 'protected' (the folder holds the recipe + * library, which the backend refuses to remove). `checking` keeps the + * confirm button disabled while the authoritative server-side check runs. * * This is the single-target form, used when the node maps to at most one * directory; an ambiguous node renders the multi-root list instead (see @@ -1816,6 +1830,14 @@ export class SidebarManager { 'sidebar.deleteFolderModal.symlinkMessage', {}, 'Every copy of this folder is a symbolic link; remove the link or its target outside LoRA Manager.' ); + } else if (state === 'protected') { + title.textContent = translate( + 'sidebar.deleteFolderModal.protectedTitle', {}, 'Folder is protected' + ); + message.textContent = translate( + 'sidebar.deleteFolderModal.protectedMessage', {}, + 'This folder holds the recipe library and cannot be deleted from LoRA Manager.' + ); } else { title.textContent = translate( 'sidebar.deleteFolderModal.notEmptyTitle', {}, 'Folder is not empty' diff --git a/tests/frontend/components/sidebarManager.folderManagement.test.js b/tests/frontend/components/sidebarManager.folderManagement.test.js index ba5a3a88..b7098cc3 100644 --- a/tests/frontend/components/sidebarManager.folderManagement.test.js +++ b/tests/frontend/components/sidebarManager.folderManagement.test.js @@ -794,6 +794,21 @@ describe('SidebarManager folder deletion', () => { expect(confirmBtn().style.display).toBe('none'); }); + it('blocks the delete when the folder holds the recipe library', async () => { + const protectedError = Object.assign(new Error('recipe library'), { code: 'protected' }); + const apiClient = createApiClient({ + deleteFolder: vi.fn().mockRejectedValue(protectedError), + }); + const manager = createManager(apiClient); + manager.nonEmptyFolders = new Set(['', 'full']); + + await manager.showDeleteFolderModal('empty'); + + const modal = document.getElementById('deleteFolderModal'); + expect(modal.dataset.state).toBe('protected'); + expect(confirmBtn().style.display).toBe('none'); + }); + it('keeps the confirm button disabled until the check settles', async () => { let release; const apiClient = createApiClient({ diff --git a/tests/services/test_model_file_service.py b/tests/services/test_model_file_service.py index 4b6ced28..0e25b2c4 100644 --- a/tests/services/test_model_file_service.py +++ b/tests/services/test_model_file_service.py @@ -628,3 +628,86 @@ async def test_rename_folder_requires_path(tmp_path: Path): result = await service.rename_folder("", "renamed") assert result["success"] is False + + +@pytest.mark.asyncio +async def test_delete_folder_refuses_the_recipes_dir(tmp_path: Path, monkeypatch): + """The default recipes dir lives in the first lora root and holds no model + weight files, so the model_count check alone would let it be wiped.""" + from py.config import config + + monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + target = tmp_path / "recipes" + target.mkdir() + recipe_file = target / "abc123.recipe.json" + recipe_file.write_text("{}", encoding="utf-8") + + result = await service.delete_folder(str(target)) + + assert result["success"] is False + assert result["code"] == "protected" + assert recipe_file.exists() + + +@pytest.mark.asyncio +async def test_delete_folder_refuses_an_ancestor_of_the_recipes_dir( + tmp_path: Path, monkeypatch +): + """Deleting a parent of the recipes dir wipes the library just the same.""" + from py.services.settings_manager import get_settings_manager + + recipes_dir = tmp_path / "data" / "recipes" + recipes_dir.mkdir(parents=True) + manager = get_settings_manager() + monkeypatch.setitem(manager.settings, "recipes_path", str(recipes_dir)) + + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + result = await service.delete_folder(str(tmp_path / "data")) + + assert result["success"] is False + assert result["code"] == "protected" + assert recipes_dir.is_dir() + + +@pytest.mark.asyncio +async def test_delete_folder_allows_normal_folders_next_to_the_recipes_dir( + tmp_path: Path, monkeypatch +): + from py.config import config + + monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) + (tmp_path / "recipes").mkdir() + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + target = tmp_path / "recipes_backup" + target.mkdir() + + result = await service.delete_folder(str(target)) + + assert result["success"] is True + assert not target.exists() + + +@pytest.mark.asyncio +async def test_rename_folder_refuses_the_recipes_dir(tmp_path: Path, monkeypatch): + from py.config import config + + monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) + scanner = FakeScanner([tmp_path]) + service = ModelMoveService(scanner, "lora") + + target = tmp_path / "recipes" + target.mkdir() + + result = await service.rename_folder(str(target), "recipes2") + + assert result["success"] is False + assert result["code"] == "protected" + assert target.is_dir() + assert scanner.renamed_folders == [] diff --git a/tests/services/test_model_scanner.py b/tests/services/test_model_scanner.py index 409e5a57..d3e7c576 100644 --- a/tests/services/test_model_scanner.py +++ b/tests/services/test_model_scanner.py @@ -2859,3 +2859,93 @@ def test_root_display_labels_dedupe_by_parent_segments(monkeypatch): label = _root_display_labels([long_root])[long_root] assert len(label) <= 40 assert label.endswith("loras") + + +@pytest.mark.asyncio +async def test_scan_excludes_recipes_dir_from_all_folders(tmp_path: Path, monkeypatch): + """The default recipes dir lives under the first lora root; it is a + reserved directory and must never appear in the folder tree.""" + _create_files(tmp_path) + recipes = tmp_path / "recipes" + (recipes / "sub").mkdir(parents=True) + # A stray weight file inside the recipes dir is not indexed either. + (recipes / "stray.txt").write_text("stray", encoding="utf-8") + monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)]) + + scanner = DummyScanner(tmp_path) + await scanner._initialize_cache() + cache = await scanner.get_cached_data() + + assert not any("/recipes/" in item["file_path"] for item in cache.raw_data) + all_folders = await scanner.get_all_folders() + assert "recipes" not in all_folders + assert "recipes/sub" not in all_folders + + +@pytest.mark.asyncio +async def test_all_folders_backfill_excludes_recipes_dir(tmp_path: Path, monkeypatch): + _create_files(tmp_path) + (tmp_path / "recipes").mkdir() + monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)]) + + scanner = DummyScanner(tmp_path) + await scanner._initialize_cache() + cache = await scanner.get_cached_data() + cache.all_folders = None + + await scanner.get_all_folders() + for _ in range(200): + if not scanner._all_folders_backfill_running: + break + await asyncio.sleep(0.01) + + assert scanner._all_folders_backfill_running is False + assert cache.all_folders is not None + assert "recipes" not in cache.all_folders + assert "nested" in cache.all_folders + + +@pytest.mark.asyncio +async def test_get_all_folders_filters_recipes_dir_from_legacy_snapshots( + tmp_path: Path, monkeypatch +): + """Snapshots persisted before the walk exclusion still carry the recipes + dir; readers filter it out until the next scan rewrites the list.""" + _create_files(tmp_path) + monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)]) + + scanner = DummyScanner(tmp_path) + await scanner._initialize_cache() + cache = await scanner.get_cached_data() + cache.all_folders = sorted( + set(cache.all_folders or []) | {"recipes", "recipes/sub"}, + key=lambda value: value.lower(), + ) + + all_folders = await scanner.get_all_folders() + + assert "recipes" not in all_folders + assert "recipes/sub" not in all_folders + assert "nested" in all_folders + + +def test_reconcile_walk_skips_the_recipes_dir(tmp_path: Path, monkeypatch): + (tmp_path / "recipes" / "sub").mkdir(parents=True) + (tmp_path / "keep").mkdir() + monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)]) + + result = model_scanner._walk_root_for_reconcile( + root_path=str(tmp_path), + file_extensions={".txt"}, + cached_paths=set(), + path_to_item={}, + lookups=model_scanner._CachedPathLookups(set()), + dir_claims=model_scanner._RealDirClaims(), + excluded_models=set(), + is_cancelled=lambda: False, + report_progress=lambda _files_seen: None, + ) + + assert "keep" in result.discovered_folders + assert "recipes" not in result.discovered_folders + assert "recipes/sub" not in result.discovered_folders