fix: address review — injective mirror roots, root relocation, full preview coverage, EXDEV-safe rollback

Codex review on #1124:

- P1: mirror layout root component is now <basename>-<roothash>
  (sha256 of the normalized root path), so two roots sharing a basename
  no longer map to the same mirror directory and overwrite each other's
  sidecars
- P1: changing sidecar_storage_path while centralized no longer strands
  assets in the old root — new relocate_root migration direction moves
  the whole mirror tree, rewrites preview_url prefixes inside sidecars,
  reconciles scanner caches, and prunes the emptied old tree; the
  settings UI detects the path change and offers the relocation
- P2: migration enumerates the same preview candidates as
  find_preview_file — case-insensitive variants (model.WEBP) and the
  legacy .example.0.jpeg suffix — instead of exact lowercase
  PREVIEW_EXTENSIONS only
- P2: _rollback_model_staging restores staged files with the
  EXDEV-tolerant mover, so a failed undoable-delete staging no longer
  strands a cross-filesystem centralized sidecar copy

Tests: same-basename root injectivity, mixed-case/example preview
migration, relocate_root happy path + guards + route 400, frontend
relocation prompt flow. Verified end-to-end in a sandboxed standalone
server: uppercase/legacy previews migrate, root relocation moves the
tree and the list API serves the new locations immediately without a
rescan.
This commit is contained in:
Will Miao
2026-09-26 12:24:17 +08:00
parent 16430aef21
commit a6fca8612f
22 changed files with 592 additions and 71 deletions
@@ -281,4 +281,60 @@ describe('SettingsManager sidecar storage', () => {
expect(migrateBtn.disabled).toBe(false);
});
});
describe('handleSidecarStoragePathChange', () => {
it('offers root relocation when the path changes in centralized mode', async () => {
const manager = createManager();
const { pathInput } = appendSidecarControls();
const modal = appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'centralized', sidecar_storage_path: '/old/root' };
manager._loadedSidecarStoragePath = '/old/root';
pathInput.value = '/new/root';
mockFetchOk();
const changePromise = manager.handleSidecarStoragePathChange();
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
await changePromise;
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
body: JSON.stringify({ direction: 'relocate_root', force: true, old_root: '/old/root' }),
}));
expect(manager._loadedSidecarStoragePath).toBe('/new/root');
});
it('does not prompt when the path changes in alongside mode', async () => {
const manager = createManager();
const { pathInput } = appendSidecarControls();
appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'alongside', sidecar_storage_path: '/old/root' };
manager._loadedSidecarStoragePath = '/old/root';
pathInput.value = '/new/root';
mockFetchOk();
await manager.handleSidecarStoragePathChange();
const migrateCalls = global.fetch.mock.calls.filter(([url]) => url === '/api/lm/sidecars/migrate');
expect(migrateCalls).toHaveLength(0);
});
it('shows a deferred notice when relocation is cancelled', async () => {
const manager = createManager();
const { pathInput } = appendSidecarControls();
const modal = appendMigrationModal();
state.global.settings = { sidecar_storage_mode: 'centralized', sidecar_storage_path: '/old/root' };
manager._loadedSidecarStoragePath = '/old/root';
pathInput.value = '/new/root';
mockFetchOk();
const changePromise = manager.handleSidecarStoragePathChange();
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
await changePromise;
const migrateCalls = global.fetch.mock.calls.filter(([url]) => url === '/api/lm/sidecars/migrate');
expect(migrateCalls).toHaveLength(0);
expect(showToast).toHaveBeenCalledWith('settings.sidecarStorage.migrationDeferred', {}, 'info');
});
});
});