fix(sidecars): keep mirrored sidecars when a model root moves

Centralized sidecars were addressed by a hash of the model root's absolute
path, so moving or renaming a root produced a new mirror directory. The
scanner then found no sidecar there, rebuilt default metadata, and silently
lost favorites, notes, tags and usage tips for every model under that root,
leaving the old metadata orphaned on disk.

Mirrors are now addressed by a root identity pinned in
<sidecar_root>/.lm-sidecar-roots.json. The identity starts as the existing
deterministic <basename>-<path digest> -- so pre-existing mirrors keep
resolving even if the map is lost, and a relocated sidecar root keeps its
names -- and is re-anchored to the root's new path when it moves, matched by
basename and recorded sample directories. Ambiguous matches are never
guessed: the mirror is left untouched and reported.

Also:
- drop the <library> path segment (single-library direction); a legacy
  library prefix is only recognised while adopting an existing mirror, which
  also keeps the unreleased centralized layout usable
- surface stranded mirrors in Doctor and in the log instead of silently
  rebuilding sidecars
- keep the default alongside mode untouched: the identity map is loaded and
  reconciled lazily, only while centralized storage is in use
This commit is contained in:
Will Miao
2026-09-28 21:11:38 +08:00
parent 0a262cbe0c
commit c3a9350155
18 changed files with 1223 additions and 61 deletions
@@ -19,13 +19,22 @@ from py.services.model_lifecycle_service import (
from py.services.pending_delete_service import PendingDeleteService
from py.services.settings_manager import get_settings_manager
from py.utils.metadata_manager import MetadataManager
from py.utils.sidecar_paths import root_mirror_component
from py.utils.sidecar_paths import reset_root_map_cache, root_mirror_component
def _normalize(path) -> str:
return str(path).replace(os.sep, "/")
@pytest.fixture(autouse=True)
def _isolate_root_map():
"""Root identities are cached per sidecar root; keep tests independent."""
reset_root_map_cache()
yield
reset_root_map_cache()
@pytest.fixture
def library_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Single checkpoint root under tmp_path; every other root emptied."""
@@ -59,11 +68,10 @@ def centralized(library_root: Path, tmp_path: Path) -> Path:
def _mirror_dir(library_root: Path, sidecar_root: Path, *rel: str) -> Path:
"""Expected mirror directory for a library-relative path."""
"""Expected mirror directory for a root-relative path."""
library = get_settings_manager().get_active_library_name()
component = root_mirror_component(str(library_root))
return sidecar_root.joinpath(library, component, *rel)
component = root_mirror_component(str(library_root), sidecar_root=str(sidecar_root))
return sidecar_root.joinpath(component, *rel)
def _write_sidecar(
@@ -12,13 +12,22 @@ import pytest
from py.config import config
from py.services.settings_manager import get_settings_manager
from py.services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase
from py.utils.sidecar_paths import root_mirror_component
from py.utils.sidecar_paths import reset_root_map_cache, root_mirror_component
def _normalize(path) -> str:
return str(path).replace(os.sep, "/")
@pytest.fixture(autouse=True)
def _isolate_root_map():
"""Root identities are cached per sidecar root; keep tests independent."""
reset_root_map_cache()
yield
reset_root_map_cache()
@pytest.fixture
def library_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Single lora root under tmp_path; every other root emptied."""
@@ -54,11 +63,10 @@ def _set_mode(mode: str) -> None:
def _mirror_dir(library_root: Path, sidecar_root: Path, *rel: str) -> Path:
"""Expected mirror directory for a library-relative path."""
"""Expected mirror directory for a root-relative path."""
library = get_settings_manager().get_active_library_name()
component = root_mirror_component(str(library_root))
return sidecar_root.joinpath(library, component, *rel)
component = root_mirror_component(str(library_root), sidecar_root=str(sidecar_root))
return sidecar_root.joinpath(component, *rel)
def _write_model(directory: Path, stem: str) -> Path: