mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 19:21:27 -03:00
Compare commits
7
Commits
f1d3ac0cdc
...
5c2b2aedcc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c2b2aedcc | ||
|
|
ebc31fb963 | ||
|
|
9659df6ad9 | ||
|
|
04d131e9dc | ||
|
|
78fe6282c7 | ||
|
|
0c00ee22fc | ||
|
|
5fd4946b1f |
@@ -0,0 +1,202 @@
|
||||
---
|
||||
slug: undo-delete-staging
|
||||
status: drafting
|
||||
intent: clear
|
||||
review_required: false
|
||||
pending-action: write .omo/plans/undo-delete-staging.md
|
||||
approach: "Option B: delayed physical deletion with Undo. Backend: same-volume rename to per-root staging dir (.lm-pending-delete/) [updated 2026-08: model staging moved to a SIBLING dir inside each deleted model's own folder — see 'Symlink fix (2026-08)' under Decisions] + manifest JSON (batch_id, expires_at, staged->original map) + purge (30s TTL timer + startup sweep + opportunistic) + undo-delete endpoint + settings toggle 'skip undo'. Small files (recipes: JSON+preview) copy to global staging under settings dir instead of rename. Frontend: extend toast system with action button + 30s countdown; delete flows (single model / recipe / bulk / duplicates) consume batch_id from delete response and show Undo toast; expired undo -> 'undo expired' toast. Plus confirm-modal friction (C-friction, NO type-to-confirm): delete button delay-activation 1.5s + modal shows file size 'will free X GB' + Cancel gets initial focus. i18n keys + sync_translation_keys.py."
|
||||
---
|
||||
|
||||
# Draft: undo-delete-staging
|
||||
|
||||
## Components (topology ledger)
|
||||
<!-- Lock the SHAPE before depth. One row per top-level component that can succeed or fail independently. -->
|
||||
<!-- id | outcome (one line) | status: active|deferred | evidence path -->
|
||||
- backend staging module (stage/purge/undo + manifest + per-volume dir resolution) | new module, active | pending exploration: model_lifecycle_service.py delete_model / delete_model_artifacts
|
||||
- delete endpoints return batch_id (model/recipe/bulk/duplicates) | active | pending exploration: handlers + response shapes
|
||||
- undo-delete HTTP endpoint + route registration | active | pending exploration: route registrar pattern
|
||||
- purge scheduling (30s timer + startup sweep + opportunistic) | active | pending exploration: app on_startup hooks
|
||||
- settings toggle "skip undo window" | active | pending exploration: settings service read pattern
|
||||
- frontend toast extension (action button + countdown) | active | pending exploration: showToast impl
|
||||
- frontend delete flows consume batch_id + Undo toast | active | pending exploration: call sites
|
||||
- confirm-modal friction (delay-activate + size display + cancel focus) | active | pending exploration: modal focus behavior
|
||||
- i18n keys + sync_translation_keys.py | active | known
|
||||
|
||||
## Open assumptions (announced defaults)
|
||||
<!-- Record any default you adopt instead of asking, so the user can veto it at the gate. -->
|
||||
<!-- assumption | adopted default | rationale | reversible? -->
|
||||
- Undo window TTL = 30s | 30s balances space-freeing intent vs accident recovery | yes (constant)
|
||||
- Staging dir name: `.lm-pending-delete/` under each model root; recipes: `{settings_dir}/.lm-pending-delete/` | hidden, same-volume [updated 2026-08: same-volume is now guaranteed by sibling staging inside the model's own folder, not by the root location], consistent | yes
|
||||
- Staging failure falls back to existing hard delete | user intent is delete; staging is best-effort; hard delete likely fails identically under same conditions | yes
|
||||
- Purge on startup uses expires_at (not purge-all) so a <30s restart with live tab can still undo | robust, matches client-side timer | yes
|
||||
- Settings toggle label: "Delete permanently immediately (skip undo window)" | power users freeing space | yes
|
||||
- C-friction: delete button enabled after 1.5s + modal shows freed size; NO type-to-confirm (user vetoed) | user explicitly rejected type-to-confirm | n/a
|
||||
- Bulk/duplicates delete: one batch id for whole action, one undo restores all | simplest consistent semantics | yes
|
||||
|
||||
## Findings (cited - path:lines)
|
||||
|
||||
### Backend
|
||||
- `delete_model_artifacts` (py/services/model_lifecycle_service.py:19-48) = physical delete via os.remove; patterns: main file + `{name}.metadata.json` + PREVIEW_EXTENSIONS (py/utils/constants.py:22-37). ALSO called by ModelScanner.bulk_delete_models (py/services/model_scanner.py:2221) - single swap point covers bulk models.
|
||||
- `ModelLifecycleService.delete_model` (model_lifecycle_service.py:101-154): fetches `cached_entry` (111-116) - SNAPSHOT available for cache restore; after delete: cache.raw_data removal + resort + bump_cache_version (136-143), `_hash_index.remove_by_path` (145-146), `_sync_update_for_model` (148; update-service only, no recipe JSON rewrites - recipe refs are hash-based, re-resolve on restore), `_persist_current_cache` (150-152), returns `{"success": True, "deleted_files": [...]}` (154).
|
||||
- Handler `delete_model` (py/routes/handlers/model_handlers.py:478-492): POST /api/lm/{prefix}/delete; response passthrough; `_broadcast_models_changed()` (57-74) after success; 400 `{"success":false,"error"}`; 500 plain text.
|
||||
- Recipe delete: handler (recipe_handlers.py:1422-1438) DELETE /api/lm/recipe/{recipe_id} -> persistence_service.delete_recipe (py/services/recipes/persistence_service.py:193-209): os.remove(recipe_json_path) + os.remove(image_path) (204-206), recipe_scanner.remove_recipe (208), returns `{"success": true, "message": ...}`. PersistenceResult dataclass (20-25).
|
||||
- Bulk models: POST /api/lm/{prefix}/bulk-delete (model_route_registrar.py:39) -> handler (model_handlers.py:974-994) -> lifecycle_service.bulk_delete_models (model_lifecycle_service.py:308-318) -> scanner.bulk_delete_models (model_scanner.py:2181-2269) which calls delete_model_artifacts per file (2221) + `_batch_update_cache_for_deleted_models` (2271-2335); response `{"success","status","total_deleted","total_attempted","cache_updated","results"}` (2254-2269).
|
||||
- Bulk recipes: POST /api/lm/recipes/bulk-delete (recipe_route_registrar.py:50) -> handler (recipe_handlers.py:1554-1573) -> persistence_service.bulk_delete (persistence_service.py:439-482): per-id os.remove x2 (464-466), recipe_scanner.bulk_remove (472); response `{"success","deleted","failed","total_deleted","total_failed"}` (474-482).
|
||||
- Duplicates: NO dedicated delete endpoints (find-only: GET /api/lm/{prefix}/find-duplicates model_route_registrar.py:59, GET /api/lm/recipes/find-duplicates recipe_route_registrar.py:49). Duplicate deletion reuses bulk-delete endpoints.
|
||||
- Startup hooks: lora_manager.py:183-187 `app.on_startup.append(lambda app: cls._initialize_services())` (ComfyUI mode, app = PromptServer.instance.app at :78); standalone.py:370-374 same (StandaloneLoraManager.add_routes). Background tasks: `asyncio.create_task(name=...)` (lora_manager.py:224-239; recipe_handlers.py:793). Singleton+asyncio.Lock pattern: model_scanner.py:40-63.
|
||||
- Settings: DEFAULT_SETTINGS (py/services/settings_manager.py:57-119), `get(key, default)` (1390-1392), get_settings_manager() (2215-2228), reset_settings_manager() (2231). Typed-bool getter example: get_skip_previously_downloaded_model_versions (1253-1262). Handlers: base_model_routes.py:70, base_recipe_routes.py:54.
|
||||
- Model roots: ModelScanner.get_model_roots base NotImplementedError (model_scanner.py:1073-1075); impls lora_scanner.py:31-45, checkpoint_scanner.py:428-441, embedding_scanner.py:24-36. `_find_root_for_file(file_path)` (model_scanner.py:1108-1124) returns containing root - for per-root staging dir computation [updated 2026-08: staging no longer uses the containing root; batches are siblings inside the model's own folder]. Business-path rule (AGENTS.md): use os.path.abspath, never realpath, for staging/undo routing.
|
||||
- Cache restore methods: ModelCache has raw_data + resort (conftest mocks: tests/conftest.py:144-154); ModelHashIndex.add_entry(sha256, file_path, autov3) (py/services/model_hash_index.py:16); RecipeScanner.add_recipe(recipe_data) (recipe_scanner.py:2136) -> recipe_cache.add_recipe (recipe_cache.py:64). No single-file incremental model rescan - use snapshot restore instead of rescan.
|
||||
- Route registrar: model_route_registrar.py:177 add_route(method, path, handler), :180 add_prefixed_route - undo endpoint can be a non-prefixed route via add_route.
|
||||
- Tests: tests/services/test_model_lifecycle_service.py (inline tmp_path files, per-test stub scanners ScannerForDelete/VersionAwareScanner etc); conftest MockScanner/MockCache/MockHashIndex (tests/conftest.py:134-212); integration fixtures tests/integration/conftest.py; lifecycle hook tests tests/routes/test_lora_manager_lifecycle.py:177-178, tests/standalone/test_standalone_server.py:83-84.
|
||||
|
||||
### Frontend
|
||||
- 5 delete call sites:
|
||||
a) Single model: static/js/utils/modalUtils.js confirmDelete (27-42) -> getModelApiClient().deleteModel(path); ignores return.
|
||||
b) Recipe single: static/js/components/RecipeCard.js confirmDeleteRecipe (405-449) - RAW fetch DELETE /api/lm/recipe/{id}, checks only response.ok, showToast toast.recipes.deletedSuccessfully, state.virtualScroller.removeItemByFilePath.
|
||||
c) Bulk: static/js/managers/BulkManager.js confirmBulkDelete (633-672) -> getActiveApiClient() (134-142) -> bulkDeleteModels(filePaths); reads result.cancelled/success/deleted_count/error.
|
||||
d) Recipe duplicates: static/js/components/DuplicatesManager.js confirmDeleteDuplicates (457-494) - RAW fetch POST /api/lm/recipes/bulk-delete, reads data.success/data.total_deleted, exitDuplicateMode().
|
||||
e) Model duplicates: static/js/components/ModelDuplicatesManager.js confirmDeleteDuplicates (710-776) - RAW fetch POST /api/lm/{type}/bulk-delete, reads data.total_deleted, then resetAndReload(true) + find-duplicates re-check.
|
||||
Bonus: static/js/components/shared/ModelVersionsTab.js:1136-1144 client.deleteModel (ignores return).
|
||||
- API clients: BaseModelApiClient.deleteModel (static/js/api/baseModelApi.js:184-216) returns true/false, shows its own toasts, does removeItemByFilePath inside; bulkDeleteModels (1591-1642) returns {success, deleted_count, failed_count, errors} or {success:false, cancelled:true}; RecipeSidebarApiClient.bulkDeleteModels (recipeApi.js:623-664) returns {success, deleted_count: total_deleted, ...}. Endpoint map apiConfig.js:56,64.
|
||||
- Toast: showToast(key, params={}, type='info', fallback=null) (static/js/utils/uiHelpers.js:136-193) - textContent only, NO action/button support; durations 2000/5000ms; CSS static/css/components/toast.css (.toast flex gap:12px - button can be added). Closest action pattern: bannerService.registerBanner actions array + onRegister (static/js/managers/BannerService.js; used uiHelpers.js:18-57).
|
||||
- i18n: locales/en.json delete keys (1303-1314 bulkDelete, 1945-1948 recipes, 1987-1991 models, 2124-2130 duplicates, 2166-2170 toast.api); t()/interpolate (static/js/i18n/index.js:193-248); translate wrapper (utils/i18nHelpers.js:13-23); sync script scripts/sync_translation_keys.py (en reference, [TODO: Translate] placeholders).
|
||||
- Refresh after undo: recipes -> window.recipeManager.loadRecipes(true) (recipes.js:359; used by FilterManager.js:752 etc) or refreshRecipes (recipeApi.js:308); models -> resetAndReload(true) from modelApiFactory (used by ModelDuplicatesManager.js:740).
|
||||
- Size for modal: card.dataset.file_size (ModelCard.js:467), formatFileSize (ModelModal.js:615).
|
||||
- Tests: tests/frontend/utils/uiHelpers.dom.test.js (toast), api/recipeApi.bulk.test.js, components/duplicatesManager.test.js, components/modelDuplicatesManager.test.js, pages/*Page.test.js, i18n tests tests/i18n/test_i18n.py.
|
||||
|
||||
## Decisions (with rationale)
|
||||
|
||||
1. Same-volume rename staging for model files (atomic, no copy cost for multi-GB files); cross-volume rename forbidden. [CORRECTED 2026-08: "same-volume because under the containing root" was only true for plain directories — nested symlinked subdirs could cross volumes. Superseded by sibling staging: `.lm-pending-delete/<batch_id>/` inside the deleted model's own folder makes stage/undo same-device by construction; see "Symlink fix (2026-08)" below.]
|
||||
2. Copy-to-global-staging for recipes (small files; avoids recipe JSON vs preview image cross-volume problem).
|
||||
3. Manifest JSON files are the only state - no DB changes. Manifest includes model cached_entry snapshot for exact cache restore (no rescan needed).
|
||||
4. Undo endpoint returns restored paths; expired batch -> 404-style error -> frontend 'undo expired' toast.
|
||||
5. Skip-undo setting honored server-side (no batch_id in response -> no undo toast client-side).
|
||||
6. Staging failure falls back to existing hard delete (best-effort undo, never blocks delete).
|
||||
7. Undo window TTL = 30s constant (PENDING_DELETE_TTL_SECONDS); startup sweep uses expires_at (survives restart; browser-tab timer survives).
|
||||
8. Purge triple-trigger: per-batch asyncio timer task + on_startup sweep + opportunistic purge at each stage/undo.
|
||||
9. Frontend: new showActionToast (keep showToast signature untouched; extract shared createToastElement/appendToast internals); undo click -> shared handleUndoDelete(batchId, refreshFn); full list refresh after undo (recipes: window.recipeManager.loadRecipes(true); models: resetAndReload(true)).
|
||||
10. C-friction wave (NO type-to-confirm - user vetoed): delete buttons delay-activate 1.5s after modal open, initial focus on Cancel, model delete modal gains "permanently deleted from disk" warning + file size display (card.dataset.file_size + formatFileSize).
|
||||
11. Model cache restore on undo: append snapshot to cache.raw_data (dedupe by file_path) + resort + bump_cache_version + _persist_current_cache + _hash_index.add_entry + _broadcast_models_changed. Recipe restore: copy back files + recipe_scanner.add_recipe(recipe_data loaded from restored JSON).
|
||||
|
||||
### Symlink fix (2026-08)
|
||||
|
||||
Post-execution addendum (plan `.omo/plans/undo-delete-symlink-fix.md`, commits 5fd4946b / 0c00ee22):
|
||||
|
||||
12. Model staging moved from `<model_root>/.lm-pending-delete/<batch_id>/` to `<model_dir>/.lm-pending-delete/<batch_id>/` (sibling of the model artifacts, inside the deleted model's own folder). Stage/undo renames are same-device BY CONSTRUCTION — EXDEV is impossible even when the business path traverses nested symlinks to other volumes (the decision-1 "containing root" guarantee covered only plain directories). EXDEV remains possible only for cross-volume merges, which keep the batch_ids-array fallback. Accepted edge: deleting the model's whole FOLDER during the 30s window destroys that batch (undo returns 404). Batch discovery uses an in-memory registry (`_known_batch_dirs`) with a startup reconciliation scan (`purge_expired(scan_roots=True)`) covering restarts and crash leftovers. Recipe batches unchanged (copy-based settings-dir staging with the `_restore_file` EXDEV fallback).
|
||||
|
||||
## Scope IN
|
||||
|
||||
- Model single delete (model_handlers delete_model / model_lifecycle_service)
|
||||
- Recipe delete (recipe_handlers delete_recipe / persistence_service)
|
||||
- Bulk delete (models scanner + recipes persistence) + duplicates (reuse bulk endpoints)
|
||||
- Undo endpoint POST /api/lm/undo-delete (models + recipes, one batch space)
|
||||
- Purge: timer + startup sweep + opportunistic
|
||||
- Settings toggle delete_undo_enabled + settings page checkbox
|
||||
- Frontend: showActionToast + all 5 delete flows + shared undo handler
|
||||
- C-friction modal changes (delay-activate + cancel focus + warning copy + size display)
|
||||
- i18n keys + sync_translation_keys.py
|
||||
- Backend + frontend tests
|
||||
|
||||
## Scope OUT (Must NOT have)
|
||||
|
||||
- NO type-to-confirm / hold-to-confirm friction (user vetoed)
|
||||
- NO OS trash integration (send2trash) in this iteration
|
||||
- NO persistent recycle-bin UI (no trash browsing page)
|
||||
- NO changes to exclude/unexclude flow
|
||||
- NO DB migrations
|
||||
- NO new dependencies (no send2trash)
|
||||
- NO changes to download flows
|
||||
- NO recipe-JSON rewriting on model undo (hash-based refs re-resolve themselves)
|
||||
|
||||
## Open questions
|
||||
|
||||
None - all implementation details resolved by exploration. Design decisions settled in conversation (B+C, no type-to-confirm).
|
||||
|
||||
## Approval gate
|
||||
status: approved
|
||||
<!-- Approach approved -> rerun scaffold without --draft-only, run Metis gap analysis, APPEND todo batches, fill TL;DR last, run structural self-check, then Phase 4 handoff. -->
|
||||
|
||||
## Review round state (ulw-plan-review-round-state-contract)
|
||||
```json
|
||||
{
|
||||
"transition": "replace",
|
||||
"phase": "review_round_initialized",
|
||||
"applies_when": ["retry_after_plan_change"],
|
||||
"atomic": true,
|
||||
"review_required": true,
|
||||
"plan_path": ".omo/plans/undo-delete-staging.md",
|
||||
"plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc",
|
||||
"review_round_id": "rr-undo-del-20260811-006",
|
||||
"round_status": "active",
|
||||
"pending-action": "review .omo/plans/undo-delete-staging.md",
|
||||
"review": {
|
||||
"momus": { "status": "pending", "workspace_root": "/mnt/data/reinstall-backup-2026-04-12/data/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager", "runtime_home": null, "target": ".omo/plans/undo-delete-staging.md", "round_id": "rr-undo-del-20260811-006", "plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc", "launch_id": null, "session": null, "result": null },
|
||||
"independent": { "status": "pending", "workspace_root": "/mnt/data/reinstall-backup-2026-04-12/data/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager", "runtime_home": null, "target": ".omo/plans/undo-delete-staging.md", "round_id": "rr-undo-del-20260811-006", "plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc", "launch_id": null, "session": null, "result": null }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Review results + fix/retry ledger
|
||||
|
||||
### Round 1 (rr-undo-del-20260811-001, plan sha256 6c52bf99...)
|
||||
- momus: APPROVE (non-blocking notes: todo1+7 duplicate DEFAULT_SETTINGS key -> fixed todo 7 to verify-only; "batch_ids" plural in todos 8/9 acceptance -> fixed; purge OSError note -> folded into todo 1 purge semantics)
|
||||
- independent (oracle): CHANGES_REQUESTED
|
||||
- BLOCKING S1: scanner walks would index .lm-pending-delete staged files as ghost entries -> fixed: todo 1 now mandates scanner walk exclusion at model_scanner.py:706/:867/:1404/_process_model_file + acceptance (o) scanner-visibility test
|
||||
- BLOCKING S2: manifest lacks model_type, undo could restore into wrong cache/hash index -> fixed: manifest now carries model_type + todo 5 resolves per-type scanner via registrar pattern + acceptance (b) checkpoint-batch test
|
||||
- S3 merged-batch expires_at re-anchor -> fixed: merge_batches re-anchors now+TTL in todo 1 + todo 3/4 assertions
|
||||
- S4 manifest-less dir policy -> fixed: quarantine to <batch_id>.orphaned, never delete (todo 1 + acceptance g)
|
||||
- S5 partial-undo retry semantics -> fixed: per-entry restored flag write-through + retry test (acceptance e)
|
||||
- S6 purge locked-file failure semantics -> fixed: skip file, keep batch, never rmtree past errors (todo 1 + acceptance i)
|
||||
- T8 undo-after-restart test -> fixed: todo 5 acceptance (f)
|
||||
- T9 recipe undo -> re-delete test -> fixed: todo 5 acceptance (h)
|
||||
- T7 rescan-stale-entry test -> fixed: todo 5 acceptance (g)
|
||||
- Route registration pinned to shared routes class per mode (NOT per-model-type registrar which registers 3x) -> fixed: todo 5 now creates py/routes/pending_delete_routes.py registered once in lora_manager.py:170-172 + standalone.py:356-358 + duplicate-route test (e)
|
||||
- Version-index staleness on single-delete undo -> fixed: todo 5 follows bulk cache-update pattern incl. rebuild_version_index (model_scanner.py:2324)
|
||||
- Cancelled-bulk batch_id frontend handling -> fixed: todo 9 shows action toast on cancelled+staged-subset
|
||||
- Single-instance assumption -> added to Scope OUT
|
||||
- Occupied-refusal loss UX -> accepted-intent documented in success criteria + modal copy
|
||||
|
||||
### Round 2 (rr-undo-del-20260811-002, plan sha256 f3d52235...)
|
||||
- momus: APPROVE (all 12 round-1 fixes verified present; zero dead references; non-blocking nits only)
|
||||
- independent (oracle): CHANGES_REQUESTED
|
||||
- BLOCK-1: merge_batches file-movement semantics unspecified (silent data-loss vector) -> fixed: todo 1 now specifies move-into-winner-dir + entry re-point + loser-dirs-removed-only-when-empty + abort-on-move-failure (all batches intact) + merge inside service lock + acceptance (k) file-survival assertions + acceptance (l) merge-failure abort test
|
||||
- BLOCK-2: same-file parallel edits within waves (todo 5 vs 6 on lora_manager.py; todo 8 vs 9 on baseModelApi.js) -> fixed: waves/matrix now serialize 5->6 and 8->9 with explicit reasons; matrix updated
|
||||
- Recommended: checkpoint_scanner.py:331 exclusion -> fixed (todo 1 + acceptance p); S5 pre-check skips restored:true entries -> fixed (todo 1); _tags_count restore on undo -> fixed (todo 5 + acceptance j); undo-blind flows documented (ModelVersionsTab + misc_handlers:2456) -> fixed (todo 8 note + Scope OUT); merge-failure no-merge fallback contract (batch_ids array) -> fixed (todos 3/4/9)
|
||||
|
||||
### Round 3 (rr-undo-del-20260811-003, plan sha256 8f2dfd46...)
|
||||
- momus: APPROVE (all round-2 fixes verified present + spot-checked refs; no new contradictions)
|
||||
- independent (oracle): CHANGES_REQUESTED
|
||||
- BLOCKING A: merged batches never timer-purged after re-anchor (winner's original timer no-ops at old expiry; no fresh timer for re-anchored expiry; idle server -> merged batch lingers, violating "30s purge" success criterion; affects EVERY bulk delete) -> fixed: todo 1 merge_batches now ARMS A FRESH PURGE TIMER for the winner with re-anchored expiry + acceptance (q) fresh-timer test + purge_expired must enumerate ALL scanner types' roots (explicit in todo 1)
|
||||
- BLOCKING B: dependency matrix contradicted same-file policy for todos 8/9<->11 (5 shared files) and 12<->11 -> fixed: todo 11 now "Blocked by: 8, 9 (same files...)"; todo 12 blocked by 11 (sync after 11); wave text updated (11, then 12 AFTER 11); "Can parallelize with" columns corrected
|
||||
- BLOCKING C: frontend batch_ids sequential-undo fallback has NO test + merge->undo loser-restore + merge->purge assertions missing -> fixed: todo 9 acceptance now tests the batch_ids fallback path; todo 1 acceptance now has (k2)/(k3)
|
||||
- Notes folded: sub-second toast-tail expiry race accepted; EXDEV fallback = NORMAL path for cross-volume bulks [annotated 2026-08: after the sibling-staging fix, EXDEV can only arise during cross-volume MERGES, never during single stage/undo renames]
|
||||
|
||||
### Round 4 (rr-undo-del-20260811-004, plan sha256 179e7ff7...)
|
||||
- momus: APPROVE (round-3 fixes verified; one non-blocking nit: todo 11 inline "Blocked by: —" stale -> fixed to "8, 9")
|
||||
- independent (oracle): CHANGES_REQUESTED
|
||||
- BLOCKING GAP-1 (NEW, introduced by round-3 fix): todo 8 handleUndoDelete always-refresh/always-toast contract contradicted todo 9's sequential loop "exactly ONE final refresh" -> fixed: handleUndoDelete(batchId, refreshFn, {showToast, refresh}) suppression options; todo 9 loop uses suppressed calls + one final refresh/toast; acceptance extended (loop failure mid-way -> stop + error toast + no final refresh; 404 body discrimination expired vs occupied)
|
||||
- BLOCKING GAP-2: no cross-type purge enumeration test -> fixed: todo 1 acceptance (r) purges expired batches across lora root + checkpoint root + recipe staging dir in one call
|
||||
- Non-blocking folded: GAP-3 404-copy discrimination -> fixed in todo 8 (d); GAP-4 merge partial-failure rollback direction (move back + restore manifests, extended (l) asserts sequential constituent undo still restores everything) -> fixed in todo 1; GAP-5 post-restart timer-loss residual gap documented -> fixed in todo 6; GAP-6 usage_stats.py:424 walk added to exclusion mandate + todo 5 acceptance (k) embeddings undo test
|
||||
|
||||
### Round 5 (rr-undo-del-20260811-005, plan sha256 dfaa39ea...)
|
||||
- momus: APPROVE (all round-4 fixes verified; no new contradictions)
|
||||
- independent (oracle): CHANGES_REQUESTED
|
||||
- BLOCK-1: lock-ordering deadlock ambiguity (asyncio.Lock not re-entrant: opportunistic purge_expired called while stage/undo hold the lock would deadlock on first use) -> fixed: todo 1 now has explicit LOCK HIERARCHY (lock acquired ONLY by stage/merge/undo/purge_batch; purge_expired is lock-free and must be called BEFORE lock acquisition); todo 6 (c) updated with the same rule + acceptance (u) lock-no-deadlock test
|
||||
- BLOCK-2: purge edge semantics unspecified -> fixed: purge_batch treats missing staged files (partially-restored batches) as already-purged (FileNotFoundError silent no-op); sweep skips `.orphaned`-suffixed dirs (quarantine is terminal); acceptance (s) partially-restored purge + (t) quarantine-terminal tests
|
||||
- Non-blocking folded: todo 2/3 test-file collision -> todo 3's bulk tests moved to tests/services/test_model_scanner.py; todo 9 (d) DuplicatesManager refreshFn stated explicitly (recipes loadRecipes / models resetAndReload); modal-copy + bulk-count trade-offs acknowledged in success criteria; acceptance (r) extended with embeddings root
|
||||
|
||||
### Round 6 (rr-undo-del-20260811-006, plan sha256 8cf7c9be...)
|
||||
- momus: APPROVE (all round-5 fixes verified; no new contradictions; references verified)
|
||||
- independent (oracle): APPROVE — no blocking issues; all round-5 items fixed with working, tested solutions; no new race/data-loss/consistency defects
|
||||
- Deferred optional improvements (non-blocking, recorded for executor awareness; plan file left untouched to preserve the approved digest):
|
||||
1. Tag-count asymmetry: single delete_model never decrements _tags_count (lifecycle 101-154), bulk does (scanner 2297-2303); undo re-increment is exact for bulk, over-counts for single until rescan (cosmetic, self-healing). Optional fix riding in todo 2: decrement tags in the single-delete path to mirror bulk.
|
||||
2. Todo 5 factual nit: ModelCache.resort() already rebuilds the version index — explicit rebuild in undo is belt-and-braces, no action needed.
|
||||
3. Todo 8 premise nit: ModelVersionsTab call ignores deleteModel's return entirely — nothing breaks, no adaptation needed.
|
||||
4. Todo 3's pytest command includes test_model_lifecycle_service.py which todo 2 edits in the same wave — run that file's tests after todo 2 lands.
|
||||
5. merge_batches with a missing/quarantined constituent id: any sane fallback (abort -> batch_ids, or skip missing) acceptable — files stay staged either way.
|
||||
|
||||
## Review lifecycle
|
||||
- rounds: 6 (rr-undo-del-20260811-001..006); final round both lanes APPROVE
|
||||
- final live-plan validation: sha256 = 8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc — MATCHES approved round-6 digest
|
||||
- status: APPROVED — ready for execution handoff ($start-work undo-delete-staging)
|
||||
File diff suppressed because one or more lines are too long
+3
-2
@@ -443,7 +443,6 @@
|
||||
"label": "Bereits heruntergeladene Modellversionen überspringen",
|
||||
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Nach Modell gruppieren",
|
||||
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
|
||||
@@ -1281,9 +1280,11 @@
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "Modell löschen",
|
||||
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"deleteRecipe": {
|
||||
"recoverableWarning": "Diese Aktion kann 30 Sekunden lang rückgängig gemacht werden."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "Modell ausschließen",
|
||||
"message": "Sind Sie sicher, dass Sie dieses Modell ausschließen möchten? Ausgeschlossene Modelle erscheinen nicht in Suchergebnissen oder Modelllisten."
|
||||
|
||||
+3
-2
@@ -443,7 +443,6 @@
|
||||
"label": "Skip previously downloaded model versions",
|
||||
"help": "When enabled, versions downloaded before will be skipped."
|
||||
},
|
||||
"deleteUndoEnabled": "Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Group by Model",
|
||||
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
|
||||
@@ -1281,9 +1280,11 @@
|
||||
"freesSpace": "Frees {size}",
|
||||
"title": "Delete Model",
|
||||
"message": "Are you sure you want to delete this model and all associated files?",
|
||||
"permanentWarning": "This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"deleteRecipe": {
|
||||
"recoverableWarning": "This action can be undone for 30 seconds."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "Exclude Model",
|
||||
"message": "Are you sure you want to exclude this model? Excluded models won't appear in searches or model lists."
|
||||
|
||||
+3
-2
@@ -443,7 +443,6 @@
|
||||
"label": "Omitir versiones de modelos previamente descargadas",
|
||||
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Agrupar por modelo",
|
||||
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
|
||||
@@ -1281,9 +1280,11 @@
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "Eliminar modelo",
|
||||
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"deleteRecipe": {
|
||||
"recoverableWarning": "Esta acción se puede deshacer durante 30 segundos."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "Excluir modelo",
|
||||
"message": "¿Estás seguro de que quieres excluir este modelo? Los modelos excluidos no aparecerán en búsquedas o listas de modelos."
|
||||
|
||||
+3
-2
@@ -443,7 +443,6 @@
|
||||
"label": "Ignorer les versions de modèles précédemment téléchargées",
|
||||
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Grouper par modèle",
|
||||
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
|
||||
@@ -1281,9 +1280,11 @@
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "Supprimer le modèle",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"deleteRecipe": {
|
||||
"recoverableWarning": "Cette action peut être annulée pendant 30 secondes."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "Exclure le modèle",
|
||||
"message": "Êtes-vous sûr de vouloir exclure ce modèle ? Les modèles exclus n'apparaîtront pas dans les recherches ou listes de modèles."
|
||||
|
||||
+3
-2
@@ -443,7 +443,6 @@
|
||||
"label": "דלג על גרסאות מודלים שהורדו בעבר",
|
||||
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "קיבוץ לפי דגם",
|
||||
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||
@@ -1281,9 +1280,11 @@
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "מחק מודל",
|
||||
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"deleteRecipe": {
|
||||
"recoverableWarning": "ניתן לבטל פעולה זו תוך 30 שניות."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "החרג מודל",
|
||||
"message": "האם אתה בטוח שברצונך להחריג מודל זה? מודלים מוחרגים לא יופיעו בחיפושים או ברשימות מודלים."
|
||||
|
||||
+3
-2
@@ -443,7 +443,6 @@
|
||||
"label": "以前にダウンロードしたモデルバージョンをスキップ",
|
||||
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "モデルでグループ化",
|
||||
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||
@@ -1281,9 +1280,11 @@
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "モデルを削除",
|
||||
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"deleteRecipe": {
|
||||
"recoverableWarning": "この操作は30秒以内であれば元に戻せます。"
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "モデルを除外",
|
||||
"message": "このモデルを除外してもよろしいですか?除外されたモデルは検索やモデルリストに表示されません。"
|
||||
|
||||
+3
-2
@@ -443,7 +443,6 @@
|
||||
"label": "이전에 다운로드한 모델 버전 건너뛰기",
|
||||
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "모델별 그룹화",
|
||||
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||
@@ -1281,9 +1280,11 @@
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "모델 삭제",
|
||||
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"deleteRecipe": {
|
||||
"recoverableWarning": "이 작업은 30초 이내에 실행 취소할 수 있습니다."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "모델 제외",
|
||||
"message": "이 모델을 제외하시겠습니까? 제외된 모델은 검색이나 모델 목록에 나타나지 않습니다."
|
||||
|
||||
+3
-2
@@ -443,7 +443,6 @@
|
||||
"label": "Пропускать ранее загруженные версии моделей",
|
||||
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Группировать по модели",
|
||||
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
|
||||
@@ -1281,9 +1280,11 @@
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "Удалить модель",
|
||||
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"deleteRecipe": {
|
||||
"recoverableWarning": "Это действие можно отменить в течение 30 секунд."
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "Исключить модель",
|
||||
"message": "Вы уверены, что хотите исключить эту модель? Исключенные модели не будут отображаться в поиске или списках моделей."
|
||||
|
||||
+3
-2
@@ -443,7 +443,6 @@
|
||||
"label": "跳过已下载的模型版本",
|
||||
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分组",
|
||||
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||
@@ -1281,9 +1280,11 @@
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "删除模型",
|
||||
"message": "你确定要删除此模型及所有相关文件吗?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"deleteRecipe": {
|
||||
"recoverableWarning": "此操作可在 30 秒内撤销。"
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "排除模型",
|
||||
"message": "你确定要排除此模型吗?被排除的模型不会出现在搜索或模型列表中。"
|
||||
|
||||
+3
-2
@@ -443,7 +443,6 @@
|
||||
"label": "跳過已下載的模型版本",
|
||||
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
||||
},
|
||||
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分組",
|
||||
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||
@@ -1281,9 +1280,11 @@
|
||||
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||
"title": "刪除模型",
|
||||
"message": "您確定要刪除此模型及所有相關檔案嗎?",
|
||||
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
|
||||
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||
},
|
||||
"deleteRecipe": {
|
||||
"recoverableWarning": "此操作可在 30 秒內復原。"
|
||||
},
|
||||
"excludeModel": {
|
||||
"title": "排除模型",
|
||||
"message": "您確定要排除此模型嗎?被排除的模型將不會出現在搜尋或模型列表中。"
|
||||
|
||||
+6
-3
@@ -251,11 +251,14 @@ class LoraManager:
|
||||
# Startup sweep: purge pending-delete batches that expired during a
|
||||
# previous run. Non-blocking (fire-and-forget); purge_expired only
|
||||
# removes already-expired batches, so a staged undo that survived a
|
||||
# restart stays restorable. Covers both plugin and standalone modes
|
||||
# (StandaloneLoraManager reuses this classmethod).
|
||||
# restart stays restorable. scan_roots=True runs the reconciliation
|
||||
# pass first so leftover batches (the in-process registry is empty
|
||||
# after a restart) are re-discovered on disk. Covers both plugin
|
||||
# and standalone modes (StandaloneLoraManager reuses this
|
||||
# classmethod).
|
||||
pending_delete_service = await get_pending_delete_service()
|
||||
asyncio.create_task(
|
||||
pending_delete_service.purge_expired(),
|
||||
pending_delete_service.purge_expired(scan_roots=True),
|
||||
name="pending_delete_startup_sweep",
|
||||
)
|
||||
|
||||
|
||||
@@ -39,14 +39,13 @@ from typing import (
|
||||
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils import settings_paths
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Undo window in seconds before a staged batch becomes purge-eligible.
|
||||
PENDING_DELETE_TTL_SECONDS = 30
|
||||
# Hidden staging directory name placed under each model root (and the settings
|
||||
# dir for recipes).
|
||||
# Hidden staging directory name placed inside each deleted model's own folder
|
||||
# (sibling of the model artifacts) and under the settings dir for recipes.
|
||||
PENDING_DELETE_DIR_NAME = ".lm-pending-delete"
|
||||
# Manifest file name inside every batch directory.
|
||||
MANIFEST_FILE_NAME = "manifest.json"
|
||||
@@ -97,6 +96,11 @@ class PendingDeleteService:
|
||||
# ServiceRegistry roots during sweeps so undo/purge work even before
|
||||
# every scanner is registered.
|
||||
self._known_roots: List[str] = []
|
||||
# MODEL batches only; recipe batches live in the fixed settings-dir
|
||||
# parent. Registry access is short critical sections; the lock-free
|
||||
# reconciliation scan registers concurrently.
|
||||
self._known_batch_dirs: Dict[str, str] = {}
|
||||
self._registry_lock = asyncio.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -111,19 +115,19 @@ class PendingDeleteService:
|
||||
original_file_path: str,
|
||||
cached_entry: Optional[Dict[str, Any]],
|
||||
) -> Optional[str]:
|
||||
"""Rename a model's artifacts into a per-root staging batch.
|
||||
"""Rename a model's artifacts into a sibling-of-model staging batch.
|
||||
|
||||
Returns the batch id, or ``None`` when undo is disabled, the staging
|
||||
root cannot be resolved, or staging failed (caller falls back to a
|
||||
hard delete).
|
||||
The batch dir is created inside the model file's OWN directory
|
||||
(``target_dir``), so staging/undo renames stay within one real
|
||||
directory - EXDEV is impossible even when the business path traverses
|
||||
nested symlinks to other volumes. Returns the batch id, or ``None``
|
||||
when the model root cannot be resolved, or staging failed (caller
|
||||
falls back to a hard delete).
|
||||
"""
|
||||
# LOCK-FREE section: opportunistic purge must never run while holding
|
||||
# the ops lock (the lock is not re-entrant).
|
||||
await self._opportunistic_purge()
|
||||
|
||||
if not self._undo_enabled():
|
||||
return None
|
||||
|
||||
async with self._ops_lock:
|
||||
batch_dir: Optional[str] = None
|
||||
staged_pairs: List[Dict[str, Any]] = []
|
||||
@@ -148,7 +152,7 @@ class PendingDeleteService:
|
||||
|
||||
batch_id = self._new_batch_id()
|
||||
batch_dir = os.path.join(
|
||||
os.path.join(root, PENDING_DELETE_DIR_NAME), batch_id
|
||||
os.path.abspath(target_dir), PENDING_DELETE_DIR_NAME, batch_id
|
||||
)
|
||||
os.makedirs(batch_dir, exist_ok=True)
|
||||
|
||||
@@ -176,14 +180,16 @@ class PendingDeleteService:
|
||||
)
|
||||
self._write_manifest_atomic(batch_dir, manifest)
|
||||
self._remember_root(root)
|
||||
await self._remember_batch(batch_id, batch_dir)
|
||||
# Arm the per-batch purge timer. Safe inside the lock: task
|
||||
# creation does not await, and purge_batch re-reads the
|
||||
# manifest's expires_at at fire time, so stale timers no-op.
|
||||
self._arm_purge_timer(batch_id)
|
||||
logger.info(
|
||||
"Staged model delete batch %s with %d file(s)",
|
||||
"Staged model delete batch %s with %d file(s): %s",
|
||||
batch_id,
|
||||
len(staged_pairs),
|
||||
staged_pairs[0]["original"] if staged_pairs else None,
|
||||
)
|
||||
return batch_id
|
||||
except OSError as exc:
|
||||
@@ -209,14 +215,11 @@ class PendingDeleteService:
|
||||
) -> Optional[str]:
|
||||
"""Copy a recipe JSON (and, when it exists, its image) into staging.
|
||||
|
||||
Returns the batch id, or ``None`` when undo is disabled / staging
|
||||
failed. Missing or shared preview images are skipped.
|
||||
Returns the batch id, or ``None`` when staging failed. Missing or
|
||||
shared preview images are skipped.
|
||||
"""
|
||||
await self._opportunistic_purge()
|
||||
|
||||
if not self._undo_enabled():
|
||||
return None
|
||||
|
||||
async with self._ops_lock:
|
||||
batch_dir: Optional[str] = None
|
||||
staged_pairs: List[Dict[str, Any]] = []
|
||||
@@ -246,9 +249,10 @@ class PendingDeleteService:
|
||||
self._write_manifest_atomic(batch_dir, manifest)
|
||||
self._arm_purge_timer(batch_id)
|
||||
logger.info(
|
||||
"Staged recipe delete batch %s with %d file(s)",
|
||||
"Staged recipe delete batch %s with %d file(s): %s",
|
||||
batch_id,
|
||||
len(staged_pairs),
|
||||
staged_pairs[0]["original"] if staged_pairs else None,
|
||||
)
|
||||
return batch_id
|
||||
except OSError as exc:
|
||||
@@ -296,7 +300,7 @@ class PendingDeleteService:
|
||||
|
||||
# Track (entry, original_staged_path, loser_dir) for rollback.
|
||||
moved: List[Tuple[Dict[str, Any], str, str]] = []
|
||||
processed_losers: List[str] = []
|
||||
processed_losers: List[Tuple[str, str]] = [] # (loser_id, loser_dir)
|
||||
|
||||
try:
|
||||
for loser_id in batch_ids[1:]:
|
||||
@@ -332,7 +336,7 @@ class PendingDeleteService:
|
||||
entry["staged"] = os.path.abspath(new_staged)
|
||||
winner_manifest["entries"].append(entry)
|
||||
moved.append((entry, original_staged, loser_dir))
|
||||
processed_losers.append(loser_dir)
|
||||
processed_losers.append((loser_id, loser_dir))
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Merge of %s failed after moving files: %s; rolling back",
|
||||
@@ -357,10 +361,15 @@ class PendingDeleteService:
|
||||
self._rollback_merge_moves(moved)
|
||||
return None
|
||||
|
||||
# All moves committed: remove loser dirs (must be empty by now).
|
||||
for loser_dir in processed_losers:
|
||||
# All moves committed: remove loser dirs (must be empty by now)
|
||||
# and drop them from the registry. Skipped losers (missing /
|
||||
# corrupted / same-dir) stay registered so the sweep still
|
||||
# quarantines them, exactly as before the registry existed.
|
||||
for loser_id, loser_dir in processed_losers:
|
||||
self._remove_manifest(loser_dir)
|
||||
self._remove_empty_dir(loser_dir)
|
||||
await self._forget_batch(loser_id)
|
||||
await self._remember_batch(winner_id, winner_dir)
|
||||
|
||||
# Arm a fresh purge timer for the winner with the re-anchored
|
||||
# expiry (the winner's original timer fires at the OLD expiry and
|
||||
@@ -438,33 +447,101 @@ class PendingDeleteService:
|
||||
# Remove the manifest + batch dir only after all entries restored.
|
||||
self._remove_manifest(batch_dir)
|
||||
self._remove_empty_dir(batch_dir)
|
||||
await self._forget_batch(batch_id)
|
||||
|
||||
logger.info("Restored pending-delete batch %s", batch_id)
|
||||
return self._undo_result(manifest)
|
||||
|
||||
async def purge_expired(self) -> int:
|
||||
"""Purge every expired batch across ALL model roots and the recipe dir.
|
||||
async def purge_expired(self, scan_roots: bool = False) -> int:
|
||||
"""Purge every expired batch.
|
||||
|
||||
Lock-free by design: enumerates staging parents (all scanner types via
|
||||
the ServiceRegistry plus the global recipe staging dir) and delegates
|
||||
each batch to :meth:`purge_batch`, which acquires the ops lock. Never
|
||||
call this while holding the ops lock.
|
||||
Default (registry-only): iterates a SNAPSHOT of the in-process MODEL
|
||||
batch registry plus a shallow check of the fixed recipe staging
|
||||
parent - cheap, no tree walk per delete. With ``scan_roots=True``
|
||||
(startup sweep only) a reconciliation pass re-discovers every batch
|
||||
on disk under the model roots and registers it FIRST, so crash
|
||||
leftovers and externally created batches are covered too.
|
||||
|
||||
Lock-free by design: delegates each batch to :meth:`purge_batch`,
|
||||
which acquires the ops lock. Never call this while holding the ops
|
||||
lock.
|
||||
"""
|
||||
purged = 0
|
||||
for parent in await self._get_all_staging_parents():
|
||||
if not os.path.isdir(parent):
|
||||
if scan_roots:
|
||||
await self._reconcile_scan_roots()
|
||||
# MODEL batches: snapshot so purge_batch can remove entries
|
||||
# mid-iteration without a dict-changed-size error.
|
||||
batch_ids: List[str] = [
|
||||
batch_id for batch_id, _dir in await self._registered_batch_dirs()
|
||||
]
|
||||
# RECIPE batches: fixed settings-dir parent, shallow check as before.
|
||||
recipe_parent = self._recipe_staging_parent()
|
||||
for name in self._list_dir_names(recipe_parent):
|
||||
if name.endswith(ORPHANED_SUFFIX):
|
||||
# Quarantine is terminal - never re-rename or delete.
|
||||
continue
|
||||
for name in self._list_dir_names(parent):
|
||||
if name.endswith(ORPHANED_SUFFIX):
|
||||
# Quarantine is terminal - never re-rename or delete.
|
||||
continue
|
||||
try:
|
||||
await self.purge_batch(name)
|
||||
purged += 1
|
||||
except Exception as exc: # defensive - sweep must not crash
|
||||
logger.warning("Failed to purge batch %s: %s", name, exc)
|
||||
if name not in batch_ids:
|
||||
batch_ids.append(name)
|
||||
for batch_id in batch_ids:
|
||||
try:
|
||||
await self.purge_batch(batch_id)
|
||||
purged += 1
|
||||
except Exception as exc: # defensive - sweep must not crash
|
||||
logger.warning("Failed to purge batch %s: %s", batch_id, exc)
|
||||
return purged
|
||||
|
||||
async def _reconcile_scan_roots(self) -> None:
|
||||
"""Register every pending-delete batch found under the model roots.
|
||||
|
||||
Runs at startup (``purge_expired(scan_roots=True)``) to re-discover
|
||||
batches left over from a previous process or created externally.
|
||||
Registers ALL non-orphaned batch dirs regardless of manifest validity:
|
||||
malformed/manifest-less dirs must reach ``_purge_batch_dir`` so it can
|
||||
QUARANTINE them (preserving the pre-registry sweep semantics). The
|
||||
walk only descends into dirs literally named ``.lm-pending-delete``,
|
||||
so false positives are structurally limited.
|
||||
"""
|
||||
from .model_scanner import _is_excluded_dir
|
||||
|
||||
for root in await self._get_all_model_roots():
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
visited: Set[str] = set()
|
||||
for dirpath, dirnames, _files in os.walk(
|
||||
root, followlinks=True, topdown=True
|
||||
):
|
||||
real_dir = os.path.realpath(dirpath)
|
||||
if real_dir in visited:
|
||||
# Symlink cycle: prune descent and move on.
|
||||
dirnames[:] = []
|
||||
continue
|
||||
visited.add(real_dir)
|
||||
if os.path.basename(dirpath) == PENDING_DELETE_DIR_NAME:
|
||||
# The current dir IS a staging parent (reachable only when
|
||||
# a model root itself is one): register its batches.
|
||||
await self._register_batch_candidates(dirpath)
|
||||
dirnames[:] = []
|
||||
continue
|
||||
next_dirs: List[str] = []
|
||||
for name in dirnames:
|
||||
if name == PENDING_DELETE_DIR_NAME:
|
||||
await self._register_batch_candidates(
|
||||
os.path.join(dirpath, name)
|
||||
)
|
||||
elif _is_excluded_dir(name):
|
||||
continue
|
||||
else:
|
||||
next_dirs.append(name)
|
||||
dirnames[:] = next_dirs
|
||||
|
||||
async def _register_batch_candidates(self, staging_parent: str) -> None:
|
||||
"""Register every non-orphaned batch subdir of a staging parent."""
|
||||
for name in self._list_dir_names(staging_parent):
|
||||
if name.endswith(ORPHANED_SUFFIX):
|
||||
# Quarantine is terminal - never re-register.
|
||||
continue
|
||||
await self._remember_batch(name, os.path.join(staging_parent, name))
|
||||
|
||||
async def purge_batch(self, batch_id: str) -> None:
|
||||
"""Purge one batch. Silent no-op for missing/undone/not-yet-expired.
|
||||
|
||||
@@ -476,7 +553,10 @@ class PendingDeleteService:
|
||||
batch_dir = await self._find_batch_dir(batch_id)
|
||||
if not batch_dir:
|
||||
return
|
||||
self._purge_batch_dir(batch_dir)
|
||||
if self._purge_batch_dir(batch_dir):
|
||||
# Both purge and quarantine remove the batch dir (quarantine
|
||||
# renames it to *.orphaned), so the registry entry is stale.
|
||||
await self._forget_batch(batch_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
@@ -488,18 +568,34 @@ class PendingDeleteService:
|
||||
except Exception as exc: # defensive - staging/undo must still proceed
|
||||
logger.warning("Opportunistic pending-delete purge failed: %s", exc)
|
||||
|
||||
def _undo_enabled(self) -> bool:
|
||||
try:
|
||||
return bool(get_settings_manager().get("delete_undo_enabled", True))
|
||||
except Exception as exc: # defensive - default to enabled
|
||||
logger.warning("Failed to read delete_undo_enabled setting: %s", exc)
|
||||
return True
|
||||
|
||||
def _remember_root(self, root: str) -> None:
|
||||
"""Record a root the service has staged into (in-process registry)."""
|
||||
if root and root not in self._known_roots:
|
||||
self._known_roots.append(root)
|
||||
|
||||
async def _remember_batch(self, batch_id: str, batch_dir: str) -> None:
|
||||
"""Register a MODEL batch in the in-process registry (idempotent).
|
||||
|
||||
Short critical section (dict mutation only, no I/O while holding the
|
||||
lock) so the lock-free reconciliation scan can register concurrently.
|
||||
"""
|
||||
async with self._registry_lock:
|
||||
self._known_batch_dirs[batch_id] = batch_dir
|
||||
|
||||
async def _forget_batch(self, batch_id: str) -> None:
|
||||
"""Remove a MODEL batch from the in-process registry (idempotent)."""
|
||||
async with self._registry_lock:
|
||||
self._known_batch_dirs.pop(batch_id, None)
|
||||
|
||||
async def _registered_batch_dirs(self) -> List[Tuple[str, str]]:
|
||||
"""Return a SNAPSHOT of (batch_id, batch_dir) registry pairs.
|
||||
|
||||
The snapshot lets purge iterate safely while purge_batch removes
|
||||
entries mid-loop (no dict-changed-size error).
|
||||
"""
|
||||
async with self._registry_lock:
|
||||
return list(self._known_batch_dirs.items())
|
||||
|
||||
def _find_model_root(self, scanner: Any, original_file_path: Optional[str]) -> Optional[str]:
|
||||
"""Return the configured root containing ``original_file_path``."""
|
||||
finder = getattr(scanner, "_find_root_for_file", None)
|
||||
@@ -818,18 +914,6 @@ class PendingDeleteService:
|
||||
settings_paths.get_settings_dir(create=True), PENDING_DELETE_DIR_NAME
|
||||
)
|
||||
|
||||
async def _get_all_staging_parents(self) -> List[str]:
|
||||
"""Model staging parents for every scanner type + the recipe parent."""
|
||||
parents: List[str] = []
|
||||
for root in await self._get_all_model_roots():
|
||||
parent = os.path.join(root, PENDING_DELETE_DIR_NAME)
|
||||
if parent not in parents:
|
||||
parents.append(parent)
|
||||
recipe_parent = self._recipe_staging_parent()
|
||||
if recipe_parent not in parents:
|
||||
parents.append(recipe_parent)
|
||||
return parents
|
||||
|
||||
async def _get_all_model_roots(self) -> List[str]:
|
||||
"""Collect every configured model root across all scanner types.
|
||||
|
||||
@@ -878,13 +962,81 @@ class PendingDeleteService:
|
||||
return roots
|
||||
|
||||
async def _find_batch_dir(self, batch_id: str) -> Optional[str]:
|
||||
"""Locate a batch directory across every staging parent."""
|
||||
"""Locate a batch directory.
|
||||
|
||||
Registry lookup first (fast path; stale entries are forgotten when
|
||||
their dir vanished); then a targeted scan of the model roots for a
|
||||
batch dir named exactly ``batch_id`` under a ``.lm-pending-delete``
|
||||
parent (restart / externally created batches; manifest verification
|
||||
applies so random uuid-named user dirs are never registered); finally
|
||||
the fixed recipe staging parent. Returns ``None`` (404 semantics)
|
||||
when not found.
|
||||
"""
|
||||
if not batch_id:
|
||||
return None
|
||||
for parent in await self._get_all_staging_parents():
|
||||
candidate = os.path.join(parent, batch_id)
|
||||
if os.path.isdir(candidate):
|
||||
# 1) Registry fast path.
|
||||
async with self._registry_lock:
|
||||
known = self._known_batch_dirs.get(batch_id)
|
||||
if known is not None:
|
||||
if os.path.isdir(known):
|
||||
return known
|
||||
await self._forget_batch(batch_id) # stale entry - dir is gone
|
||||
# 2) Targeted scan fallback across the model roots.
|
||||
for root in await self._get_all_model_roots():
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
candidate = await self._scan_root_for_batch(root, batch_id)
|
||||
if candidate is not None:
|
||||
await self._remember_batch(batch_id, candidate)
|
||||
return candidate
|
||||
# 3) Recipe batches: fixed settings-dir parent, shallow check.
|
||||
candidate = os.path.join(self._recipe_staging_parent(), batch_id)
|
||||
if os.path.isdir(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
async def _scan_root_for_batch(self, root: str, batch_id: str) -> Optional[str]:
|
||||
"""Search one model root for a batch dir named exactly ``batch_id``.
|
||||
|
||||
Walks the root (``followlinks=True``) with a realpath cycle guard,
|
||||
looking for ``.lm-pending-delete`` parents whose subdir matches
|
||||
``batch_id`` AND has a parseable manifest. The manifest check prevents
|
||||
random uuid-named user dirs from being treated as batches (a batch
|
||||
with no parseable manifest cannot be undone anyway).
|
||||
"""
|
||||
from .model_scanner import _is_excluded_dir
|
||||
|
||||
visited: Set[str] = set()
|
||||
for dirpath, dirnames, _files in os.walk(root, followlinks=True, topdown=True):
|
||||
real_dir = os.path.realpath(dirpath)
|
||||
if real_dir in visited:
|
||||
# Symlink cycle: prune descent and move on.
|
||||
dirnames[:] = []
|
||||
continue
|
||||
visited.add(real_dir)
|
||||
if os.path.basename(dirpath) == PENDING_DELETE_DIR_NAME:
|
||||
candidate = os.path.join(dirpath, batch_id)
|
||||
if (
|
||||
os.path.isdir(candidate)
|
||||
and self._read_manifest(candidate) is not None
|
||||
):
|
||||
return candidate
|
||||
dirnames[:] = []
|
||||
continue
|
||||
next_dirs: List[str] = []
|
||||
for name in dirnames:
|
||||
if name == PENDING_DELETE_DIR_NAME:
|
||||
candidate = os.path.join(dirpath, name, batch_id)
|
||||
if (
|
||||
os.path.isdir(candidate)
|
||||
and self._read_manifest(candidate) is not None
|
||||
):
|
||||
return candidate
|
||||
continue
|
||||
if _is_excluded_dir(name):
|
||||
continue
|
||||
next_dirs.append(name)
|
||||
dirnames[:] = next_dirs
|
||||
return None
|
||||
|
||||
def _list_dir_names(self, parent: str) -> List[str]:
|
||||
|
||||
@@ -111,7 +111,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
"group_by_model": False,
|
||||
"delete_undo_enabled": True,
|
||||
# AI / LLM provider configuration (BYOK)
|
||||
"llm_provider": "openai", # "openai" | "ollama" | "custom"
|
||||
"llm_api_key": "",
|
||||
|
||||
@@ -203,7 +203,7 @@ export class BaseModelApiClient {
|
||||
}
|
||||
const batchId = data.batch_id || null;
|
||||
if (!batchId) {
|
||||
// Not staged (undo disabled or staging failed): keep the legacy toast.
|
||||
// Not staged (staging failed): keep the legacy toast.
|
||||
// When staged, the caller shows the undo action toast instead.
|
||||
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Duplicates Manager Component
|
||||
import { showToast, showActionToast } from '../utils/uiHelpers.js';
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { armDeleteButton } from '../utils/modalUtils.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { RecipeCard } from './RecipeCard.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
@@ -449,7 +448,6 @@ export class DuplicatesManager {
|
||||
|
||||
// Use the modal manager to show the confirmation modal
|
||||
modalManager.showModal('duplicateDeleteModal');
|
||||
armDeleteButton(document.getElementById('duplicateDeleteModal'));
|
||||
} catch (error) {
|
||||
console.error('Error preparing delete:', error);
|
||||
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Model Duplicates Manager Component for LoRAs and Checkpoints
|
||||
import { showToast, showActionToast } from '../utils/uiHelpers.js';
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { armDeleteButton } from '../utils/modalUtils.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { formatDate } from '../utils/formatters.js';
|
||||
@@ -703,7 +702,6 @@ export class ModelDuplicatesManager {
|
||||
|
||||
// Use the modal manager to show the confirmation modal
|
||||
modalManager.showModal('modelDuplicateDeleteModal');
|
||||
armDeleteButton(document.getElementById('modelDuplicateDeleteModal'));
|
||||
} catch (error) {
|
||||
console.error('Error preparing delete:', error);
|
||||
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
|
||||
|
||||
@@ -9,7 +9,6 @@ import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { armDeleteButton } from '../utils/modalUtils.js';
|
||||
|
||||
class RecipeCard {
|
||||
constructor(recipe, clickHandler) {
|
||||
@@ -366,7 +365,7 @@ class RecipeCard {
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${this.recipe.title}</h3>
|
||||
<p>This action cannot be undone.</p>
|
||||
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
|
||||
@@ -378,13 +377,8 @@ class RecipeCard {
|
||||
`;
|
||||
|
||||
// Show the modal with custom content and setup callbacks
|
||||
let deleteArmTimer = null;
|
||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||
// This is the onClose callback
|
||||
if (deleteArmTimer) {
|
||||
clearTimeout(deleteArmTimer);
|
||||
deleteArmTimer = null;
|
||||
}
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
deleteBtn.textContent = 'Delete';
|
||||
@@ -404,8 +398,6 @@ class RecipeCard {
|
||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
||||
|
||||
deleteArmTimer = armDeleteButton(deleteModal);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing delete confirmation:', error);
|
||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { armDeleteButton } from '../utils/modalUtils.js';
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
@@ -630,7 +629,6 @@ export class BulkManager {
|
||||
}
|
||||
|
||||
modalManager.showModal('bulkDeleteModal');
|
||||
armDeleteButton(document.getElementById('bulkDeleteModal'));
|
||||
}
|
||||
|
||||
async confirmBulkDelete() {
|
||||
|
||||
@@ -1111,12 +1111,6 @@ export class SettingsManager {
|
||||
includeTriggerWordsCheckbox.checked = state.global.settings.include_trigger_words || false;
|
||||
}
|
||||
|
||||
// Set delete undo setting (defaults to enabled)
|
||||
const deleteUndoEnabledCheckbox = document.getElementById('deleteUndoEnabled');
|
||||
if (deleteUndoEnabledCheckbox) {
|
||||
deleteUndoEnabledCheckbox.checked = state.global.settings.delete_undo_enabled ?? true;
|
||||
}
|
||||
|
||||
// Set lora syntax format
|
||||
const loraSyntaxFormatSelect = document.getElementById('loraSyntaxFormat');
|
||||
if (loraSyntaxFormatSelect) {
|
||||
|
||||
@@ -58,7 +58,6 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
strip_lora_on_copy: false,
|
||||
use_new_license_icons: true,
|
||||
group_by_model: false,
|
||||
delete_undo_enabled: true,
|
||||
llm_provider: 'openai',
|
||||
llm_api_key: '',
|
||||
llm_api_base: '',
|
||||
|
||||
@@ -3,31 +3,11 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { showActionToast } from './uiHelpers.js';
|
||||
import { translate } from './i18nHelpers.js';
|
||||
import { handleUndoDelete } from './undoHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { formatFileSize } from '../components/shared/utils.js';
|
||||
|
||||
const DELETE_BUTTON_ARM_DELAY_MS = 1500;
|
||||
|
||||
let pendingDeletePath = null;
|
||||
let pendingDeleteName = null;
|
||||
let pendingExcludePath = null;
|
||||
let pendingDeleteArmTimer = null;
|
||||
|
||||
// Delay-activates every delete button inside a delete-confirmation modal so a
|
||||
// misclick in the first moments after opening cannot confirm the deletion.
|
||||
// Returns the pending timeout id so callers can cancel it when the modal closes early.
|
||||
export function armDeleteButton(modalElement, delayMs = DELETE_BUTTON_ARM_DELAY_MS) {
|
||||
if (!modalElement) return null;
|
||||
|
||||
const deleteButtons = modalElement.querySelectorAll('.delete-btn');
|
||||
if (!deleteButtons.length) return null;
|
||||
|
||||
deleteButtons.forEach((button) => { button.disabled = true; });
|
||||
|
||||
return setTimeout(() => {
|
||||
deleteButtons.forEach((button) => { button.disabled = false; });
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
export function showDeleteModal(filePath) {
|
||||
pendingDeletePath = filePath;
|
||||
@@ -41,10 +21,6 @@ export function showDeleteModal(filePath) {
|
||||
const modal = modalManager.getModal('deleteModal').element;
|
||||
const modelInfo = modal.querySelector('.delete-model-info');
|
||||
|
||||
const undoEnabled = state.global?.settings?.delete_undo_enabled;
|
||||
const warningKey = undoEnabled
|
||||
? 'modals.deleteModel.recoverableWarning'
|
||||
: 'modals.deleteModel.permanentWarning';
|
||||
const fileSize = card?.dataset.file_size;
|
||||
const sizeLine = fileSize
|
||||
? `<br>${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}`
|
||||
@@ -55,11 +31,10 @@ export function showDeleteModal(filePath) {
|
||||
<br>
|
||||
<strong>File:</strong> ${filePath}
|
||||
<br>
|
||||
${translate(warningKey)}${sizeLine}
|
||||
${translate('modals.deleteModel.recoverableWarning')}${sizeLine}
|
||||
`;
|
||||
|
||||
modalManager.showModal('deleteModal');
|
||||
pendingDeleteArmTimer = armDeleteButton(modal);
|
||||
}
|
||||
|
||||
export async function confirmDelete() {
|
||||
@@ -90,10 +65,6 @@ export async function confirmDelete() {
|
||||
|
||||
export function closeDeleteModal() {
|
||||
modalManager.closeModal('deleteModal');
|
||||
if (pendingDeleteArmTimer) {
|
||||
clearTimeout(pendingDeleteArmTimer);
|
||||
pendingDeleteArmTimer = null;
|
||||
}
|
||||
pendingDeletePath = null;
|
||||
pendingDeleteName = null;
|
||||
}
|
||||
|
||||
@@ -811,22 +811,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="deleteUndoEnabled">
|
||||
{{ t('settings.deleteUndoEnabled') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="deleteUndoEnabled"
|
||||
onchange="settingsManager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,6 @@ vi.mock('../../../static/js/state/index.js', () => ({
|
||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
showExcludeModal: vi.fn(),
|
||||
showDeleteModal: vi.fn(),
|
||||
armDeleteButton: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
|
||||
|
||||
@@ -27,16 +27,7 @@ vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
armDeleteButton: (modalElement) => {
|
||||
if (!modalElement) return null;
|
||||
const buttons = modalElement.querySelectorAll('.delete-btn');
|
||||
buttons.forEach((button) => { button.disabled = true; });
|
||||
return setTimeout(() => {
|
||||
buttons.forEach((button) => { button.disabled = false; });
|
||||
}, 1500);
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({}));
|
||||
|
||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||
recreateVirtualScroll: recreateVirtualScrollMock,
|
||||
@@ -340,41 +331,3 @@ describe('DuplicatesManager confirmDeleteDuplicates undo flows', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DuplicatesManager deleteSelectedDuplicates delay-activate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
setCurrentPageType('recipes');
|
||||
setupDom();
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div id="duplicateDeleteModal" class="modal delete-modal">
|
||||
<div class="delete-model-info"><p><span id="duplicateDeleteCount">0</span></p></div>
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`);
|
||||
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete globalThis.modalManager;
|
||||
});
|
||||
|
||||
it('opens with the delete button disabled and enables it after 1500ms', async () => {
|
||||
const manager = new DuplicatesManager({});
|
||||
manager.selectedForDeletion.add('r1');
|
||||
|
||||
await manager.deleteSelectedDuplicates();
|
||||
|
||||
expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('duplicateDeleteModal');
|
||||
const deleteBtn = document.querySelector('#duplicateDeleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
deleteBtn.click();
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,16 +18,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
resetAndReload: resetAndReloadMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
armDeleteButton: (modalElement) => {
|
||||
if (!modalElement) return null;
|
||||
const buttons = modalElement.querySelectorAll('.delete-btn');
|
||||
buttons.forEach((button) => { button.disabled = true; });
|
||||
return setTimeout(() => {
|
||||
buttons.forEach((button) => { button.disabled = false; });
|
||||
}, 1500);
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../static/js/utils/modalUtils.js', () => ({}));
|
||||
|
||||
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
|
||||
const { state } = await import('../../../static/js/state/index.js');
|
||||
@@ -365,36 +356,3 @@ describe('ModelDuplicatesManager confirmDeleteDuplicates undo flows', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModelDuplicatesManager deleteSelectedDuplicates delay-activate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete globalThis.modalManager;
|
||||
});
|
||||
|
||||
it('opens with the delete button disabled and enables it after 1500ms', async () => {
|
||||
const manager = await createManager();
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div id="modelDuplicateDeleteModal" class="modal delete-modal">
|
||||
<div class="delete-model-info"><p><span id="modelDuplicateDeleteCount">0</span></p></div>
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`);
|
||||
manager.selectedForDeletion.add(carPath);
|
||||
|
||||
await manager.deleteSelectedDuplicates();
|
||||
|
||||
expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('modelDuplicateDeleteModal');
|
||||
const deleteBtn = document.querySelector('#modelDuplicateDeleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
RECIPE_CARD_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
RECIPE_API_MODULE,
|
||||
MODEL_CARD_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
BULK_MANAGER_MODULE,
|
||||
I18N_MODULE,
|
||||
UNDO_HELPERS_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
STATE_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname,
|
||||
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const showModalMock = vi.fn();
|
||||
const closeModalMock = vi.fn();
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
showActionToast: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(RECIPE_API_MODULE, () => ({
|
||||
updateRecipeMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_CARD_MODULE, () => ({
|
||||
configureModelCardVideo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: showModalMock,
|
||||
closeModal: closeModalMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(BULK_MANAGER_MODULE, () => ({
|
||||
bulkManager: {},
|
||||
}));
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: vi.fn((key) => key),
|
||||
}));
|
||||
|
||||
vi.mock(UNDO_HELPERS_MODULE, () => ({
|
||||
handleUndoDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
// modalUtils.js is intentionally NOT mocked — its real armDeleteButton drives
|
||||
// the delay-activate behavior under test. Its own imports are mocked below.
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(),
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('RecipeCard delete confirmation delay-activate', () => {
|
||||
let capturedOnClose;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
showModalMock.mockReset();
|
||||
closeModalMock.mockReset();
|
||||
capturedOnClose = null;
|
||||
document.body.innerHTML = '<div id="deleteModal" class="modal delete-modal"></div>';
|
||||
showModalMock.mockImplementation((id, content, onClose) => {
|
||||
if (content) {
|
||||
document.getElementById(id).innerHTML = content;
|
||||
}
|
||||
capturedOnClose = onClose;
|
||||
});
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
window.recipeManager = { loadRecipes: vi.fn() };
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.virtualScroller = { removeItemByFilePath: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete global.fetch;
|
||||
delete window.recipeManager;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
async function createCard() {
|
||||
const { RecipeCard } = await import(RECIPE_CARD_MODULE);
|
||||
const card = Object.create(RecipeCard.prototype);
|
||||
card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json', file_url: '/preview.png' };
|
||||
return card;
|
||||
}
|
||||
|
||||
it('opens with a disabled delete button that ignores clicks until 1500ms elapse', async () => {
|
||||
const card = await createCard();
|
||||
card.showDeleteConfirmation();
|
||||
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
deleteBtn.click();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
|
||||
deleteBtn.click();
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/recipe/recipe-1',
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the pending arm timer when the modal closes during the countdown', async () => {
|
||||
const card = await createCard();
|
||||
card.showDeleteConfirmation();
|
||||
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(700);
|
||||
capturedOnClose();
|
||||
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('re-arms a full 1500ms countdown when the modal is reopened', async () => {
|
||||
const card = await createCard();
|
||||
card.showDeleteConfirmation();
|
||||
|
||||
vi.advanceTimersByTime(1400);
|
||||
capturedOnClose();
|
||||
|
||||
card.showDeleteConfirmation();
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1499);
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -332,41 +332,3 @@ describe('BulkManager.confirmBulkDelete undo flows', () => {
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BulkManager.showBulkDeleteModal delay-activate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
stateStub.currentPageType = 'loras';
|
||||
stateStub.selectedModels.clear();
|
||||
stateStub.selectedModels.add('/models/a.safetensors');
|
||||
document.body.innerHTML = `
|
||||
<div id="bulkDeleteModal" class="modal delete-modal">
|
||||
<h2></h2>
|
||||
<p class="delete-message"></p>
|
||||
<div class="delete-model-info"><p></p></div>
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('opens with the delete button disabled and enables it after 1500ms', async () => {
|
||||
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
|
||||
const bulk = new BulkManager();
|
||||
bulk.showBulkDeleteModal();
|
||||
|
||||
const deleteBtn = document.querySelector('#bulkDeleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
deleteBtn.click();
|
||||
expect(bulkDeleteModelsMock).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
modalManager: {
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => {
|
||||
const settings = {};
|
||||
return {
|
||||
state: {
|
||||
global: {
|
||||
settings,
|
||||
},
|
||||
loadingManager: {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
},
|
||||
},
|
||||
createDefaultSettings: () => ({
|
||||
language: 'en',
|
||||
delete_undo_enabled: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
DOWNLOAD_PATH_TEMPLATES: {},
|
||||
DEFAULT_PATH_TEMPLATES: {},
|
||||
MAPPABLE_BASE_MODELS: [],
|
||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||
lora: 'character, style',
|
||||
checkpoint: 'base, guide',
|
||||
embedding: 'hint',
|
||||
},
|
||||
getMappableBaseModelsDynamic: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: (_key, _params, fallback) => fallback ?? '',
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/i18n/index.js', () => ({
|
||||
i18n: {
|
||||
getCurrentLocale: () => 'en',
|
||||
setLanguage: vi.fn().mockResolvedValue(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||
configureModelCardVideo: vi.fn(),
|
||||
}));
|
||||
|
||||
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||
import { showToast } from '../../../static/js/utils/uiHelpers.js';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
|
||||
const createManager = () => {
|
||||
state.global.settings = {};
|
||||
const initSettingsSpy = vi
|
||||
.spyOn(SettingsManager.prototype, 'initializeSettings')
|
||||
.mockResolvedValue();
|
||||
const initializeSpy = vi
|
||||
.spyOn(SettingsManager.prototype, 'initialize')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const manager = new SettingsManager();
|
||||
|
||||
initSettingsSpy.mockRestore();
|
||||
initializeSpy.mockRestore();
|
||||
|
||||
return manager;
|
||||
};
|
||||
|
||||
const appendDeleteUndoCheckbox = () => {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = 'deleteUndoEnabled';
|
||||
document.body.appendChild(checkbox);
|
||||
return checkbox;
|
||||
};
|
||||
|
||||
const stubLoadSettingsSubloaders = (manager) => {
|
||||
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
|
||||
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
describe('SettingsManager delete undo toggle', () => {
|
||||
it('renders the checkbox markup with a resolvable i18n label', () => {
|
||||
const template = readFileSync(
|
||||
resolve(repoRoot, 'templates/components/modals/settings_modal.html'),
|
||||
'utf8',
|
||||
);
|
||||
const locales = JSON.parse(
|
||||
readFileSync(resolve(repoRoot, 'locales/en.json'), 'utf8'),
|
||||
);
|
||||
|
||||
// The label must resolve to real text, not fall back to the raw key.
|
||||
expect(locales.settings.deleteUndoEnabled).toBe(
|
||||
'Keep deleted items recoverable for 30 seconds (undo)',
|
||||
);
|
||||
expect(template).toContain('id="deleteUndoEnabled"');
|
||||
expect(template).toContain("t('settings.deleteUndoEnabled')");
|
||||
expect(template).toContain(
|
||||
"settingsManager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled')",
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the checkbox as unchecked when the saved setting is false', async () => {
|
||||
const manager = createManager();
|
||||
const checkbox = appendDeleteUndoCheckbox();
|
||||
checkbox.checked = true;
|
||||
|
||||
stubLoadSettingsSubloaders(manager);
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
state.global.settings = { delete_undo_enabled: false };
|
||||
|
||||
await manager.loadSettingsToUI();
|
||||
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('restores the checkbox as checked when the saved setting is true or absent', async () => {
|
||||
const manager = createManager();
|
||||
const checkbox = appendDeleteUndoCheckbox();
|
||||
|
||||
stubLoadSettingsSubloaders(manager);
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
state.global.settings = { delete_undo_enabled: true };
|
||||
await manager.loadSettingsToUI();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
checkbox.checked = false;
|
||||
state.global.settings = {};
|
||||
await manager.loadSettingsToUI();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('saves delete_undo_enabled to the backend when the checkbox is toggled', async () => {
|
||||
const manager = createManager();
|
||||
const checkbox = appendDeleteUndoCheckbox();
|
||||
checkbox.checked = false;
|
||||
|
||||
state.global.settings = { delete_undo_enabled: true };
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
await manager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled');
|
||||
|
||||
expect(state.global.settings.delete_undo_enabled).toBe(false);
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ delete_undo_enabled: false }),
|
||||
});
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'toast.settings.settingsUpdated',
|
||||
{ setting: 'delete undo enabled' },
|
||||
'success',
|
||||
);
|
||||
});
|
||||
|
||||
it('saves delete_undo_enabled as true when re-enabled', async () => {
|
||||
const manager = createManager();
|
||||
const checkbox = appendDeleteUndoCheckbox();
|
||||
checkbox.checked = true;
|
||||
|
||||
state.global.settings = { delete_undo_enabled: false };
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
await manager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled');
|
||||
|
||||
expect(state.global.settings.delete_undo_enabled).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ delete_undo_enabled: true }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
closeDeleteModal: closeDeleteModalMock,
|
||||
confirmExclude: confirmExcludeMock,
|
||||
closeExcludeModal: closeExcludeModalMock,
|
||||
armDeleteButton: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
|
||||
|
||||
@@ -26,7 +26,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
closeDeleteModal: closeDeleteModalMock,
|
||||
confirmExclude: confirmExcludeMock,
|
||||
closeExcludeModal: closeExcludeModalMock,
|
||||
armDeleteButton: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
|
||||
@@ -36,7 +36,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
|
||||
closeDeleteModal: closeDeleteModalMock,
|
||||
confirmExclude: confirmExcludeMock,
|
||||
closeExcludeModal: closeExcludeModalMock,
|
||||
armDeleteButton: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
|
||||
|
||||
@@ -66,9 +66,6 @@ describe('translate() with real en.json locale', () => {
|
||||
it('resolves delete-model modal keys with interpolation', () => {
|
||||
installWindowI18n();
|
||||
|
||||
expect(translate('modals.deleteModel.permanentWarning')).toBe(
|
||||
'This will permanently delete the file from disk.',
|
||||
);
|
||||
expect(translate('modals.deleteModel.recoverableWarning')).toBe(
|
||||
'This will permanently delete the file after 30 seconds unless you undo.',
|
||||
);
|
||||
@@ -77,14 +74,6 @@ describe('translate() with real en.json locale', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the delete-undo settings label', () => {
|
||||
installWindowI18n();
|
||||
|
||||
expect(translate('settings.deleteUndoEnabled')).toBe(
|
||||
'Keep deleted items recoverable for 30 seconds (undo)',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the raw key when no translation exists (fallback contract)', () => {
|
||||
installWindowI18n();
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
MODAL_UTILS_MODULE,
|
||||
@@ -7,7 +7,6 @@ const {
|
||||
UI_HELPERS_MODULE,
|
||||
I18N_MODULE,
|
||||
UNDO_HELPERS_MODULE,
|
||||
STATE_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
MODAL_UTILS_MODULE: new URL('../../../static/js/utils/modalUtils.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
@@ -15,7 +14,6 @@ const {
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const deleteModelMock = vi.fn();
|
||||
@@ -136,75 +134,6 @@ describe('modalUtils confirmDelete undo flow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('modalUtils armDeleteButton delay-activate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
deleteModelMock.mockReset();
|
||||
showModalMock.mockReset();
|
||||
closeModalMock.mockReset();
|
||||
document.body.innerHTML = `
|
||||
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
|
||||
<div id="deleteModal">
|
||||
<div class="delete-model-info"></div>
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('opens with the delete button disabled and enables it after exactly 1500ms', async () => {
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1499);
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('clicking the disabled delete button fires nothing', async () => {
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
deleteBtn.click();
|
||||
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
expect(deleteModelMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closing during the countdown clears the timer and reopening re-arms a full 1500ms', async () => {
|
||||
const { showDeleteModal, closeDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
|
||||
vi.advanceTimersByTime(1400);
|
||||
closeDeleteModal();
|
||||
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||
|
||||
// Reopen — the stale timer must not enable the button early
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1499);
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modalUtils showDeleteModal warning copy and size line', () => {
|
||||
beforeEach(() => {
|
||||
showModalMock.mockReset();
|
||||
@@ -220,19 +149,11 @@ describe('modalUtils showDeleteModal warning copy and size line', () => {
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.global.settings.delete_undo_enabled = true;
|
||||
});
|
||||
|
||||
function modelInfoHtml() {
|
||||
return document.querySelector('#deleteModal .delete-model-info').innerHTML;
|
||||
}
|
||||
|
||||
it('shows the recoverable warning when delete_undo_enabled is truthy', async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.global.settings.delete_undo_enabled = true;
|
||||
|
||||
it('always shows the recoverable warning', async () => {
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
@@ -240,27 +161,6 @@ describe('modalUtils showDeleteModal warning copy and size line', () => {
|
||||
expect(modelInfoHtml()).not.toContain('modals.deleteModel.permanentWarning');
|
||||
});
|
||||
|
||||
it('shows the permanent warning when delete_undo_enabled is falsy', async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.global.settings.delete_undo_enabled = false;
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
|
||||
expect(modelInfoHtml()).not.toContain('modals.deleteModel.recoverableWarning');
|
||||
});
|
||||
|
||||
it('falls back to the neutral permanent warning when the setting is unavailable', async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
delete state.global.settings.delete_undo_enabled;
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
|
||||
});
|
||||
|
||||
it('appends a formatted "Frees {size}" line when the card carries a file size', async () => {
|
||||
translateMock.mockImplementation((key, params) =>
|
||||
params && params.size ? `${key} ${params.size}` : key
|
||||
|
||||
@@ -10,7 +10,6 @@ import pytest
|
||||
|
||||
from py.services.model_lifecycle_service import ModelLifecycleService, _require_path_in_library_roots
|
||||
from py.services.pending_delete_service import PENDING_DELETE_DIR_NAME, _reset_pending_delete_service
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.utils.models import LoraMetadata
|
||||
|
||||
@@ -953,11 +952,11 @@ def _make_delete_service(scanner: Any) -> ModelLifecycleService:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_stages_file_when_undo_enabled(tmp_path: Path):
|
||||
"""Undo enabled (the default): artifacts are renamed into a
|
||||
``.lm-pending-delete/<batch_id>/`` staging dir under the model root, the
|
||||
response carries the batch_id, the cache entry is removed and the cache
|
||||
is persisted (``_persist_calls`` tracked by ``ScannerForDelete``)."""
|
||||
async def test_delete_model_stages_file(tmp_path: Path):
|
||||
"""Artifacts are renamed into a ``.lm-pending-delete/<batch_id>/`` staging
|
||||
dir under the model root, the response carries the batch_id, the cache
|
||||
entry is removed and the cache is persisted (``_persist_calls`` tracked by
|
||||
``ScannerForDelete``)."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model_path = root / "model.safetensors"
|
||||
@@ -999,41 +998,6 @@ async def test_delete_model_stages_file_when_undo_enabled(tmp_path: Path):
|
||||
assert scanner._persist_calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_hard_deletes_when_undo_disabled(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""delete_undo_enabled=false: old os.remove behavior, batch_id is None and
|
||||
no staging directory is ever created."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model_path = root / "model.safetensors"
|
||||
model_path.write_bytes(b"content")
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
monkeypatch.setattr(
|
||||
settings_manager,
|
||||
"get",
|
||||
lambda key, default=None: False
|
||||
if key == "delete_undo_enabled"
|
||||
else default,
|
||||
)
|
||||
|
||||
scanner = ScannerForDelete(
|
||||
raw_data=[{"file_path": str(model_path)}],
|
||||
roots=[str(root)],
|
||||
)
|
||||
service = _make_delete_service(scanner)
|
||||
|
||||
result = await service.delete_model(str(model_path))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["batch_id"] is None
|
||||
assert result["deleted_files"]
|
||||
assert not model_path.exists()
|
||||
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_falls_back_when_staging_fails(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
@@ -22,7 +22,6 @@ from py.services.pending_delete_service import (
|
||||
_reset_pending_delete_service,
|
||||
)
|
||||
from py.services.persistent_model_cache import PersistentModelCache, DEFAULT_LICENSE_FLAGS
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
from py.utils.civitai_utils import build_license_flags
|
||||
from py.utils.models import BaseModelMetadata
|
||||
|
||||
@@ -1166,33 +1165,6 @@ async def test_bulk_delete_merge_failure_falls_back_to_batch_ids(
|
||||
assert sorted(staged_files) == ["one.txt", "two.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_undo_disabled_hard_deletes(tmp_path: Path):
|
||||
"""delete_undo_enabled=false -> old hard delete, no batch, no staging dirs."""
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
first = root / "one.txt"
|
||||
first.write_text("one", encoding="utf-8")
|
||||
second = root / "two.txt"
|
||||
second.write_text("two", encoding="utf-8")
|
||||
scanner = _make_bulk_scanner(root, [first, second])
|
||||
|
||||
get_settings_manager().settings["delete_undo_enabled"] = False
|
||||
|
||||
result = await scanner.bulk_delete_models([str(first), str(second)])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "success"
|
||||
assert result["total_deleted"] == 2
|
||||
assert result.get("batch_id") is None
|
||||
assert "batch_ids" not in result
|
||||
|
||||
# Old hard-delete behavior: files removed, zero staging dirs created.
|
||||
assert not first.exists()
|
||||
assert not second.exists()
|
||||
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_cancelled_after_one_staged_batch_present(
|
||||
tmp_path: Path, monkeypatch
|
||||
|
||||
@@ -15,10 +15,11 @@ import asyncio
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -30,7 +31,6 @@ from py.services.pending_delete_service import (
|
||||
)
|
||||
from py.services.model_hash_index import ModelHashIndex
|
||||
from py.services.model_scanner import ModelScanner
|
||||
from py.services.settings_manager import DEFAULT_SETTINGS, get_settings_manager
|
||||
from py.utils import settings_paths
|
||||
from py.utils.models import LoraMetadata
|
||||
|
||||
@@ -451,7 +451,9 @@ async def test_g_manifestless_dir_quarantined(tmp_path: Path, monkeypatch) -> No
|
||||
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||
|
||||
service = await PendingDeleteService.get_instance()
|
||||
await service.purge_expired()
|
||||
# The batch is hand-created (unregistered): the reconciliation pass is
|
||||
# required for the default registry-only purge to discover it.
|
||||
await service.purge_expired(scan_roots=True)
|
||||
|
||||
orphaned = staging / "batch1.orphaned"
|
||||
assert orphaned.is_dir()
|
||||
@@ -474,7 +476,8 @@ async def test_h_corrupted_manifest_quarantined(tmp_path: Path, monkeypatch) ->
|
||||
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||
service = await PendingDeleteService.get_instance()
|
||||
|
||||
await service.purge_expired() # must not crash
|
||||
# Hand-created (unregistered) batch: reconciliation discovers it.
|
||||
await service.purge_expired(scan_roots=True) # must not crash
|
||||
|
||||
orphaned = staging / "batch2.orphaned"
|
||||
assert orphaned.is_dir()
|
||||
@@ -767,32 +770,6 @@ async def test_l2_merge_basename_collision_aborts_without_dropping_files(
|
||||
assert (sub_b / "model.safetensors").read_bytes() == b"model-data"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (m) delete_undo_enabled=false -> stage returns None, nothing created
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_m_undo_disabled_returns_none(tmp_path: Path) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model = root / "model.safetensors"
|
||||
model.write_bytes(b"data")
|
||||
|
||||
get_settings_manager().settings["delete_undo_enabled"] = False
|
||||
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await service.stage_model_delete(
|
||||
scanner=ScannerForStage([root]),
|
||||
target_dir=str(root),
|
||||
file_name="model",
|
||||
main_extension=".safetensors",
|
||||
original_file_path=str(model),
|
||||
cached_entry=None,
|
||||
)
|
||||
|
||||
assert batch_id is None
|
||||
assert model.exists()
|
||||
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (n) simulated OSError during staging -> rollback, no orphaned batch dir
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -835,13 +812,6 @@ async def test_n_staging_oserror_rolls_back(tmp_path: Path, monkeypatch) -> None
|
||||
assert not any(staging.iterdir())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (o) DEFAULT_SETTINGS contains delete_undo_enabled=True
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_o_default_settings_contains_undo_enabled() -> None:
|
||||
assert DEFAULT_SETTINGS.get("delete_undo_enabled") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (p) SCANNER EXCLUSION
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1055,7 +1025,8 @@ async def test_r_purge_expired_enumerates_all_scanner_types_and_recipe_dir(
|
||||
)
|
||||
|
||||
service = await PendingDeleteService.get_instance()
|
||||
purged = await service.purge_expired()
|
||||
# Hand-created (unregistered) batches: reconciliation pass discovers them.
|
||||
purged = await service.purge_expired(scan_roots=True)
|
||||
|
||||
assert purged >= 4
|
||||
for root in (lora_root, ckpt_root, emb_root):
|
||||
@@ -1117,12 +1088,13 @@ async def test_t_quarantine_is_terminal(tmp_path: Path, monkeypatch) -> None:
|
||||
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||
service = await PendingDeleteService.get_instance()
|
||||
|
||||
await service.purge_expired()
|
||||
# Hand-created (unregistered) batch: reconciliation discovers it.
|
||||
await service.purge_expired(scan_roots=True)
|
||||
orphaned = staging / "qbatch.orphaned"
|
||||
assert orphaned.is_dir()
|
||||
|
||||
# Second sweep must NOT re-rename or delete the quarantined dir.
|
||||
await service.purge_expired()
|
||||
await service.purge_expired(scan_roots=True)
|
||||
assert orphaned.is_dir()
|
||||
assert (orphaned / "model.safetensors").read_bytes() == b"data"
|
||||
assert not batch_dir.exists()
|
||||
@@ -1169,7 +1141,9 @@ async def test_u_lock_no_deadlock_with_concurrent_purge(tmp_path: Path, monkeypa
|
||||
cached_entry=None,
|
||||
)
|
||||
|
||||
purge_task = asyncio.create_task(service.purge_expired())
|
||||
# Hand-created (unregistered) "expired" batch: the purge task must run the
|
||||
# reconciliation pass to discover it alongside the staged "new" batch.
|
||||
purge_task = asyncio.create_task(service.purge_expired(scan_roots=True))
|
||||
stage_task = asyncio.create_task(do_stage())
|
||||
results = await asyncio.gather(purge_task, stage_task, return_exceptions=True)
|
||||
|
||||
@@ -1533,3 +1507,773 @@ async def test_snap2_merge_keeps_both_snapshots(
|
||||
snap_entries = [e for e in manifest["entries"] if e.get("snapshot")]
|
||||
assert len(snap_entries) == 2
|
||||
assert {e["snapshot"]["file_path"] for e in snap_entries} == {str(a1), str(b1)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch-registry lifecycle (todo 1: in-process _known_batch_dirs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# (a) stage_model_delete registers in _known_batch_dirs
|
||||
async def test_reg_a_stage_model_registers_batch(tmp_path: Path) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await _stage_simple(service, root, "model")
|
||||
|
||||
assert service._known_batch_dirs.get(batch_id) == str(
|
||||
root / PENDING_DELETE_DIR_NAME / batch_id
|
||||
)
|
||||
|
||||
|
||||
# (b) undo success removes the entry
|
||||
async def test_reg_b_undo_success_removes_registry_entry(tmp_path: Path) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await _stage_simple(service, root, "model")
|
||||
assert batch_id in service._known_batch_dirs
|
||||
|
||||
await service.undo(batch_id)
|
||||
|
||||
assert batch_id not in service._known_batch_dirs
|
||||
|
||||
|
||||
# (c) purge_batch removes the entry after a real purge
|
||||
async def test_reg_c_purge_batch_removes_registry_entry(tmp_path: Path) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await _stage_simple(service, root, "model")
|
||||
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
|
||||
manifest_path = batch_dir / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["expires_at"] = int(time.time()) - 10
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
|
||||
await service.purge_batch(batch_id)
|
||||
|
||||
assert batch_id not in service._known_batch_dirs
|
||||
assert not batch_dir.exists()
|
||||
|
||||
|
||||
# (d) quarantine (corrupted manifest) removes the entry
|
||||
async def test_reg_d_quarantine_removes_registry_entry(tmp_path: Path) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await _stage_simple(service, root, "model")
|
||||
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
|
||||
assert batch_id in service._known_batch_dirs
|
||||
|
||||
# Corrupt the manifest: purge_batch quarantines the dir (returns True).
|
||||
(batch_dir / "manifest.json").write_text("{ not valid json !!!")
|
||||
|
||||
await service.purge_batch(batch_id)
|
||||
|
||||
assert batch_id not in service._known_batch_dirs
|
||||
assert not batch_dir.exists()
|
||||
assert (batch_dir.with_name(f"{batch_id}.orphaned")).is_dir()
|
||||
|
||||
|
||||
# (e) merge success: winner present + losers removed; EXDEV-abort: unchanged
|
||||
async def test_reg_e_merge_registry_lifecycle(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
_spy_purge_timers(monkeypatch)
|
||||
service = await PendingDeleteService.get_instance()
|
||||
bid_a = await _stage_simple(service, root, "alpha")
|
||||
bid_b = await _stage_simple(service, root, "beta")
|
||||
bid_c = await _stage_simple(service, root, "gamma")
|
||||
assert set(service._known_batch_dirs) == {bid_a, bid_b, bid_c}
|
||||
|
||||
# Merge success: winner stays, processed loser forgotten, untouched batch stays.
|
||||
assert await service.merge_batches([bid_a, bid_b]) == bid_a
|
||||
assert bid_a in service._known_batch_dirs
|
||||
assert bid_b not in service._known_batch_dirs
|
||||
assert bid_c in service._known_batch_dirs
|
||||
|
||||
# EXDEV-abort: registry untouched.
|
||||
def exdev_rename(src: str, dst: str) -> None:
|
||||
raise OSError(errno.EXDEV, "Invalid cross-device link", src, dst)
|
||||
|
||||
monkeypatch.setattr("py.services.pending_delete_service.os.rename", exdev_rename)
|
||||
before = dict(service._known_batch_dirs)
|
||||
assert await service.merge_batches([bid_a, bid_c]) is None
|
||||
assert dict(service._known_batch_dirs) == before
|
||||
|
||||
|
||||
# (f) _reset_pending_delete_service clears the registry
|
||||
async def test_reg_f_reset_clears_registry(tmp_path: Path) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await _stage_simple(service, root, "model")
|
||||
assert service._known_batch_dirs
|
||||
|
||||
_reset_pending_delete_service()
|
||||
|
||||
fresh = await PendingDeleteService.get_instance()
|
||||
assert fresh is not service
|
||||
assert fresh._known_batch_dirs == {}
|
||||
|
||||
|
||||
# (g) scan_roots=True reconciles externally created batches (expired purged,
|
||||
# non-expired registered); the registry-only default does NOT find them
|
||||
async def test_reg_g_reconciliation_finds_external_batches(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||
|
||||
expired_dir = root / PENDING_DELETE_DIR_NAME / "ext-expired"
|
||||
expired_dir.mkdir(parents=True)
|
||||
(expired_dir / "old.safetensors").write_bytes(b"old")
|
||||
_write_batch_manifest(
|
||||
expired_dir,
|
||||
batch_id="ext-expired",
|
||||
kind="model",
|
||||
model_type="loras",
|
||||
expires_at=int(time.time()) - 10,
|
||||
entries=[
|
||||
{
|
||||
"staged": str(expired_dir / "old.safetensors"),
|
||||
"original": str(root / "old.safetensors"),
|
||||
"restored": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
fresh_dir = root / PENDING_DELETE_DIR_NAME / "ext-fresh"
|
||||
fresh_dir.mkdir(parents=True)
|
||||
(fresh_dir / "new.safetensors").write_bytes(b"new")
|
||||
_write_batch_manifest(
|
||||
fresh_dir,
|
||||
batch_id="ext-fresh",
|
||||
kind="model",
|
||||
model_type="loras",
|
||||
expires_at=int(time.time()) + 100,
|
||||
entries=[
|
||||
{
|
||||
"staged": str(fresh_dir / "new.safetensors"),
|
||||
"original": str(root / "new.safetensors"),
|
||||
"restored": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
service = await PendingDeleteService.get_instance()
|
||||
|
||||
# Registry-only default: the externally created batches are invisible.
|
||||
await service.purge_expired()
|
||||
assert expired_dir.is_dir()
|
||||
assert fresh_dir.is_dir()
|
||||
assert "ext-expired" not in service._known_batch_dirs
|
||||
assert "ext-fresh" not in service._known_batch_dirs
|
||||
|
||||
# Reconciliation pass: expired one purged, non-expired one registered.
|
||||
await service.purge_expired(scan_roots=True)
|
||||
|
||||
assert not expired_dir.exists()
|
||||
assert not (root / "old.safetensors").exists()
|
||||
assert fresh_dir.is_dir()
|
||||
assert (fresh_dir / "new.safetensors").exists()
|
||||
assert "ext-expired" not in service._known_batch_dirs
|
||||
assert service._known_batch_dirs.get("ext-fresh") == str(fresh_dir)
|
||||
|
||||
|
||||
# (h) _find_batch_dir with cleared registry locates + registers (restart sim)
|
||||
async def test_reg_h_find_batch_dir_restart_simulation(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await _stage_simple(service, root, "model")
|
||||
assert batch_id in service._known_batch_dirs
|
||||
|
||||
# Simulate a restart: the in-process registry is empty but the batch dir
|
||||
# is still on disk.
|
||||
service._known_batch_dirs.clear()
|
||||
|
||||
found = await service._find_batch_dir(batch_id)
|
||||
|
||||
assert found == str(root / PENDING_DELETE_DIR_NAME / batch_id)
|
||||
assert service._known_batch_dirs.get(batch_id) == found
|
||||
|
||||
# Undo works after the restart simulation.
|
||||
await service.undo(batch_id)
|
||||
assert (root / "model.safetensors").read_bytes() == b"model-data"
|
||||
assert batch_id not in service._known_batch_dirs
|
||||
|
||||
|
||||
# (i) purge iteration uses a snapshot: no dict-changed-size when entries are
|
||||
# removed mid-iteration
|
||||
async def test_reg_i_purge_iteration_uses_snapshot(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||
service = await PendingDeleteService.get_instance()
|
||||
ids = [await _stage_simple(service, root, f"m{i}") for i in range(5)]
|
||||
|
||||
for batch_id in ids:
|
||||
manifest_path = root / PENDING_DELETE_DIR_NAME / batch_id / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["expires_at"] = int(time.time()) - 10
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
|
||||
# Every purge removes its registry entry mid-loop; the snapshot makes this
|
||||
# safe (iterating the dict directly would raise RuntimeError).
|
||||
await service.purge_expired()
|
||||
|
||||
assert service._known_batch_dirs == {}
|
||||
for batch_id in ids:
|
||||
assert not (root / PENDING_DELETE_DIR_NAME / batch_id).exists()
|
||||
|
||||
|
||||
# (j) STARTUP SWEEP PIN: the startup sweep task passes scan_roots=True
|
||||
async def test_reg_j_startup_sweep_passes_scan_roots_true(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from py import lora_manager
|
||||
|
||||
sweep_calls: List[Dict[str, Any]] = []
|
||||
|
||||
class _SpySweepService:
|
||||
async def purge_expired(self, scan_roots: bool = False) -> int:
|
||||
sweep_calls.append({"scan_roots": scan_roots})
|
||||
return 0
|
||||
|
||||
async def _fake_get_service() -> _SpySweepService:
|
||||
return _SpySweepService()
|
||||
|
||||
monkeypatch.setattr(lora_manager, "get_pending_delete_service", _fake_get_service)
|
||||
|
||||
async def _stub(*args: Any, **_kwargs: Any) -> Any:
|
||||
return args[0] if args else None
|
||||
|
||||
class _DummyScanner:
|
||||
async def initialize_in_background(self) -> None:
|
||||
return None
|
||||
|
||||
dummy = _DummyScanner()
|
||||
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_civitai_client", lambda: _stub())
|
||||
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_download_manager", lambda: _stub())
|
||||
monkeypatch.setattr(
|
||||
lora_manager.ServiceRegistry, "get_download_queue_service", lambda: _stub()
|
||||
)
|
||||
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_backup_service", lambda: _stub())
|
||||
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_websocket_manager", lambda: _stub())
|
||||
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_lora_scanner", lambda: _stub(dummy))
|
||||
monkeypatch.setattr(
|
||||
lora_manager.ServiceRegistry, "get_checkpoint_scanner", lambda: _stub(dummy)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lora_manager.ServiceRegistry, "get_embedding_scanner", lambda: _stub(dummy)
|
||||
)
|
||||
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_recipe_scanner", lambda: _stub(dummy))
|
||||
|
||||
from py.services import metadata_service as metadata_service_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
metadata_service_module,
|
||||
"initialize_metadata_providers",
|
||||
_stub,
|
||||
)
|
||||
|
||||
from py.services.llm_service import LLMService
|
||||
|
||||
monkeypatch.setattr(LLMService, "get_instance", _stub)
|
||||
|
||||
async def _fake_migration() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
lora_manager.ExampleImagesMigration,
|
||||
"check_and_run_migrations",
|
||||
staticmethod(_fake_migration),
|
||||
)
|
||||
|
||||
captured: List[Any] = []
|
||||
|
||||
class _DummyTask:
|
||||
def add_done_callback(self, _cb: Any) -> None: # pragma: no cover - stub
|
||||
pass
|
||||
|
||||
def done(self) -> bool: # pragma: no cover - stub
|
||||
return False
|
||||
|
||||
def _capture_task(coro: Any, *args: Any, **kwargs: Any) -> _DummyTask:
|
||||
captured.append(coro)
|
||||
return _DummyTask()
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_task", _capture_task)
|
||||
|
||||
try:
|
||||
await lora_manager.LoraManager._initialize_services()
|
||||
finally:
|
||||
sweep_coro: Any = None
|
||||
for coro in captured:
|
||||
qualname = getattr(coro.cr_code, "co_qualname", "")
|
||||
if "_SpySweepService.purge_expired" in qualname:
|
||||
sweep_coro = coro
|
||||
else:
|
||||
coro.close()
|
||||
if sweep_coro is not None:
|
||||
# The sweep task body only runs when awaited; execute just the
|
||||
# spy's purge_expired so it records its invocation arguments.
|
||||
await sweep_coro
|
||||
|
||||
# The startup sweep must invoke purge_expired with scan_roots=True (the
|
||||
# reconciliation flag) - forgetting it would break restart cleanup.
|
||||
assert sweep_calls == [{"scan_roots": True}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Todo 2: SIBLING-OF-MODEL STAGING (model file in a SUBDIR of the scanner root)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# (a) staging lands in <model_dir>/.lm-pending-delete/<batch_id>, NOT under the
|
||||
# scanner root - manifest entries' staged paths live under the sibling dir.
|
||||
async def test_sibling1_stage_model_in_subdir_uses_sibling_dir(tmp_path: Path) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
sub = root / "nested"
|
||||
sub.mkdir()
|
||||
model = sub / "model.safetensors"
|
||||
model.write_bytes(b"sibling-data")
|
||||
metadata = sub / "model.metadata.json"
|
||||
metadata.write_bytes(b"{}")
|
||||
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await service.stage_model_delete(
|
||||
scanner=ScannerForStage([root]),
|
||||
target_dir=str(sub),
|
||||
file_name="model",
|
||||
main_extension=".safetensors",
|
||||
original_file_path=str(model),
|
||||
cached_entry=None,
|
||||
)
|
||||
assert batch_id is not None
|
||||
|
||||
sibling_dir = sub / PENDING_DELETE_DIR_NAME / batch_id
|
||||
assert sibling_dir.is_dir()
|
||||
# The OLD location (under the scanner root) must NOT be created.
|
||||
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||
|
||||
manifest = json.loads((sibling_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert len(manifest["entries"]) == 2
|
||||
for entry in manifest["entries"]:
|
||||
assert str(entry["staged"]).startswith(str(sibling_dir))
|
||||
assert (sibling_dir / "model.safetensors").read_bytes() == b"sibling-data"
|
||||
assert (sibling_dir / "model.metadata.json").exists()
|
||||
assert not model.exists()
|
||||
assert not metadata.exists()
|
||||
|
||||
|
||||
# (b) undo of a sibling-staged batch restores the files byte-identically.
|
||||
async def test_sibling2_undo_restores_byte_identically(tmp_path: Path) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
sub = root / "nested"
|
||||
sub.mkdir()
|
||||
model = sub / "model.safetensors"
|
||||
model.write_bytes(b"payload-1")
|
||||
preview = sub / "model.preview.png"
|
||||
preview.write_bytes(b"payload-2")
|
||||
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await service.stage_model_delete(
|
||||
scanner=ScannerForStage([root]),
|
||||
target_dir=str(sub),
|
||||
file_name="model",
|
||||
main_extension=".safetensors",
|
||||
original_file_path=str(model),
|
||||
cached_entry=None,
|
||||
)
|
||||
assert batch_id is not None
|
||||
sibling_dir = sub / PENDING_DELETE_DIR_NAME / batch_id
|
||||
assert sibling_dir.is_dir()
|
||||
assert not model.exists()
|
||||
assert not preview.exists()
|
||||
|
||||
await service.undo(batch_id)
|
||||
|
||||
assert model.read_bytes() == b"payload-1"
|
||||
assert preview.read_bytes() == b"payload-2"
|
||||
assert not sibling_dir.exists()
|
||||
assert batch_id not in service._known_batch_dirs
|
||||
|
||||
|
||||
# (c) ROOT GATING: _find_model_root -> None skips staging entirely.
|
||||
async def test_sibling3_root_gating_skips_staging(tmp_path: Path, monkeypatch) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model = root / "model.safetensors"
|
||||
model.write_bytes(b"keep-me")
|
||||
|
||||
service = await PendingDeleteService.get_instance()
|
||||
monkeypatch.setattr(service, "_find_model_root", lambda _scanner, _path: None)
|
||||
|
||||
batch_id = await service.stage_model_delete(
|
||||
scanner=ScannerForStage([root]),
|
||||
target_dir=str(root),
|
||||
file_name="model",
|
||||
main_extension=".safetensors",
|
||||
original_file_path=str(model),
|
||||
cached_entry=None,
|
||||
)
|
||||
|
||||
assert batch_id is None
|
||||
assert model.read_bytes() == b"keep-me"
|
||||
assert not (root / PENDING_DELETE_DIR_NAME).exists()
|
||||
|
||||
|
||||
# QA scenario: simulated OSError on the 2nd artifact during sibling staging ->
|
||||
# rollback renames the 1st back, returns None, and leaves no orphaned sibling
|
||||
# batch dir behind.
|
||||
async def test_sibling4_staging_oserror_rolls_back_sibling_dir(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
sub = root / "nested"
|
||||
sub.mkdir()
|
||||
a = sub / "model.safetensors"
|
||||
a.write_bytes(b"a-bytes")
|
||||
b = sub / "model.metadata.json"
|
||||
b.write_bytes(b"b-bytes")
|
||||
|
||||
service = await PendingDeleteService.get_instance()
|
||||
|
||||
real_rename = os.rename
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_rename(src: str, dst: str) -> None:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 2:
|
||||
raise OSError("simulated sibling staging failure")
|
||||
return real_rename(src, dst)
|
||||
|
||||
monkeypatch.setattr("py.services.pending_delete_service.os.rename", flaky_rename)
|
||||
|
||||
batch_id = await service.stage_model_delete(
|
||||
scanner=ScannerForStage([root]),
|
||||
target_dir=str(sub),
|
||||
file_name="model",
|
||||
main_extension=".safetensors",
|
||||
original_file_path=str(a),
|
||||
cached_entry=None,
|
||||
)
|
||||
|
||||
assert batch_id is None
|
||||
# Both artifacts rolled back; no orphaned sibling batch dir holds data.
|
||||
assert a.read_bytes() == b"a-bytes"
|
||||
assert b.read_bytes() == b"b-bytes"
|
||||
sibling = sub / PENDING_DELETE_DIR_NAME
|
||||
if sibling.exists():
|
||||
assert not any(sibling.iterdir())
|
||||
|
||||
|
||||
# (d) SCANNER EXCLUSION at a NESTED staging dir: a model staged into
|
||||
# <root>/sub/.lm-pending-delete is excluded from the walk just like the
|
||||
# root-level one (depth independence).
|
||||
async def test_p_model_walk_excludes_nested_staging_dir(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
(root / "normal.safetensors").write_bytes(b"normal")
|
||||
sub = root / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "real.safetensors").write_bytes(b"real")
|
||||
nested_staging = sub / PENDING_DELETE_DIR_NAME / "x"
|
||||
nested_staging.mkdir(parents=True)
|
||||
(nested_staging / "model.safetensors").write_bytes(b"ghost")
|
||||
(nested_staging / "model.metadata.json").write_bytes(b'{"hash_status": "pending"}')
|
||||
# Root-level staging dir for comparison.
|
||||
root_staging = root / PENDING_DELETE_DIR_NAME / "y"
|
||||
root_staging.mkdir(parents=True)
|
||||
(root_staging / "ghost2.safetensors").write_bytes(b"ghost2")
|
||||
|
||||
from py.services import model_scanner as model_scanner_module
|
||||
|
||||
async def _noop_register(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(model_scanner_module.ServiceRegistry, "register_service", _noop_register)
|
||||
monkeypatch.setenv("LORA_MANAGER_DISABLE_PERSISTENT_CACHE", "1")
|
||||
|
||||
scanner = DummyScannerForWalk(root)
|
||||
|
||||
result = await scanner._gather_model_data()
|
||||
paths = [entry["file_path"] for entry in result.raw_data]
|
||||
assert not any(PENDING_DELETE_DIR_NAME in p for p in paths)
|
||||
# Real files at both depths are still discovered.
|
||||
assert any(p.endswith("normal.safetensors") for p in paths)
|
||||
assert any(p.endswith("sub/real.safetensors") for p in paths)
|
||||
|
||||
assert scanner._count_model_files() == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Todo 3: REGRESSION SUITE for the undo-delete symlink fix
|
||||
# (real symlink round-trip, restart-undo, reconciliation, folder-deleted
|
||||
# edge, merge EXDEV-abort + sequential undo)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# (a) SYMLINK ROUND-TRIP: staging through a symlinked dir must resolve into
|
||||
# the REAL directory (sibling staging derives the batch dir from the
|
||||
# model's own dir, so a nested symlink lands in the target of the link),
|
||||
# never raise EXDEV, restore byte-identically at the business paths, and
|
||||
# purge cleanly after expiry. This is the primary regression proof:
|
||||
# PRE-FIX the batch was staged under the scanner ROOT, so
|
||||
# ``real_batch.is_dir()`` (real_dir/.lm-pending-delete/<batch>) would have
|
||||
# failed - the batch would have lived at <root>/.lm-pending-delete.
|
||||
async def test_symlink1_stage_undo_round_trip_through_symlink(tmp_path: Path) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
real_dir = tmp_path / "real_dir"
|
||||
real_dir.mkdir()
|
||||
# The business path traverses a symlink NESTED under the scanner root.
|
||||
link_dir = root / "link_dir"
|
||||
os.symlink(real_dir, link_dir, target_is_directory=True)
|
||||
|
||||
model = link_dir / "model.safetensors"
|
||||
model.write_bytes(b"model-payload")
|
||||
metadata = link_dir / "model.metadata.json"
|
||||
metadata.write_bytes(b'{"k": "v"}')
|
||||
preview = link_dir / "model.preview.webp"
|
||||
preview.write_bytes(b"preview-payload")
|
||||
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await service.stage_model_delete(
|
||||
scanner=ScannerForStage([root]),
|
||||
target_dir=str(link_dir),
|
||||
file_name="model",
|
||||
main_extension=".safetensors",
|
||||
original_file_path=str(model),
|
||||
cached_entry=None,
|
||||
)
|
||||
# Staging succeeded - no EXDEV, no silent hard-delete fallback.
|
||||
assert batch_id is not None
|
||||
|
||||
# The registry records the BUSINESS path (symlink preserved, abspath only).
|
||||
business_batch = link_dir / PENDING_DELETE_DIR_NAME / batch_id
|
||||
assert service._known_batch_dirs[batch_id] == str(business_batch)
|
||||
# ... and the dir itself resolves through the symlink into the REAL dir.
|
||||
real_batch = real_dir / PENDING_DELETE_DIR_NAME / batch_id
|
||||
assert real_batch.is_dir()
|
||||
assert os.path.realpath(str(business_batch)) == str(real_batch)
|
||||
assert (real_batch / "model.safetensors").read_bytes() == b"model-payload"
|
||||
|
||||
# Originals renamed away at the business paths.
|
||||
assert not model.exists()
|
||||
assert not metadata.exists()
|
||||
assert not preview.exists()
|
||||
|
||||
# Undo restores byte-identically AT the business paths (through the link).
|
||||
await service.undo(batch_id)
|
||||
assert (link_dir / "model.safetensors").read_bytes() == b"model-payload"
|
||||
assert (link_dir / "model.metadata.json").read_bytes() == b'{"k": "v"}'
|
||||
assert (link_dir / "model.preview.webp").read_bytes() == b"preview-payload"
|
||||
staging = real_dir / PENDING_DELETE_DIR_NAME
|
||||
assert not staging.exists() or not any(staging.iterdir())
|
||||
|
||||
# Purge after expiry leaves the REAL directory clean.
|
||||
batch_id2 = await service.stage_model_delete(
|
||||
scanner=ScannerForStage([root]),
|
||||
target_dir=str(link_dir),
|
||||
file_name="model",
|
||||
main_extension=".safetensors",
|
||||
original_file_path=str(link_dir / "model.safetensors"),
|
||||
cached_entry=None,
|
||||
)
|
||||
assert batch_id2 is not None
|
||||
real_batch2 = real_dir / PENDING_DELETE_DIR_NAME / batch_id2
|
||||
manifest_path = real_batch2 / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["expires_at"] = int(time.time()) - 10
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
|
||||
await service.purge_expired()
|
||||
|
||||
assert not (real_dir / "model.safetensors").exists()
|
||||
assert not staging.exists() or not any(staging.iterdir())
|
||||
|
||||
|
||||
# (b) RESTART-UNDO: with an empty in-process registry (simulated restart) undo
|
||||
# still locates the batch via the scan fallback, restores it, and
|
||||
# re-registers it (transiently) before the dir is removed.
|
||||
async def test_symlink2_restart_undo_empty_registry_scan_fallback(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await _stage_simple(service, root, "model")
|
||||
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
|
||||
assert batch_id in service._known_batch_dirs
|
||||
|
||||
# Simulate a restart: the batch dir survives on disk, the registry does not.
|
||||
service._known_batch_dirs.clear()
|
||||
assert service._known_batch_dirs == {}
|
||||
|
||||
# Spy on the re-registration performed by the scan fallback inside undo.
|
||||
registrations: List[Tuple[str, str]] = []
|
||||
real_remember = service._remember_batch
|
||||
|
||||
async def _spy_remember(bid: str, bdir: str) -> None:
|
||||
registrations.append((bid, bdir))
|
||||
await real_remember(bid, bdir)
|
||||
|
||||
monkeypatch.setattr(service, "_remember_batch", _spy_remember)
|
||||
|
||||
result = await service.undo(batch_id)
|
||||
|
||||
assert result["batch_id"] == batch_id
|
||||
assert (root / "model.safetensors").read_bytes() == b"model-data"
|
||||
assert not batch_dir.exists()
|
||||
# The scan fallback re-registered the batch during the undo lookup; undo
|
||||
# then forgets it once the batch dir is removed.
|
||||
assert (batch_id, str(batch_dir)) in registrations
|
||||
assert batch_id not in service._known_batch_dirs
|
||||
|
||||
|
||||
# (c) RECONCILIATION: crash leftovers hand-written in a NESTED staging parent
|
||||
# (the sibling staging location for a model in a root subdir) are found by
|
||||
# the startup sweep: expired ones purged, fresh ones registered. The
|
||||
# registry-only default does NOT discover them.
|
||||
async def test_symlink3_reconciliation_nested_staging_parents(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
nested = root / "sub"
|
||||
nested.mkdir()
|
||||
await _register_model_root(monkeypatch, lora_roots=[root])
|
||||
|
||||
expired_dir = nested / PENDING_DELETE_DIR_NAME / "crash-expired"
|
||||
expired_dir.mkdir(parents=True)
|
||||
(expired_dir / "old.safetensors").write_bytes(b"old")
|
||||
_write_batch_manifest(
|
||||
expired_dir,
|
||||
batch_id="crash-expired",
|
||||
kind="model",
|
||||
model_type="loras",
|
||||
expires_at=int(time.time()) - 10,
|
||||
entries=[
|
||||
{
|
||||
"staged": str(expired_dir / "old.safetensors"),
|
||||
"original": str(nested / "old.safetensors"),
|
||||
"restored": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
fresh_dir = nested / PENDING_DELETE_DIR_NAME / "crash-fresh"
|
||||
fresh_dir.mkdir(parents=True)
|
||||
(fresh_dir / "new.safetensors").write_bytes(b"new")
|
||||
_write_batch_manifest(
|
||||
fresh_dir,
|
||||
batch_id="crash-fresh",
|
||||
kind="model",
|
||||
model_type="loras",
|
||||
expires_at=int(time.time()) + 100,
|
||||
entries=[
|
||||
{
|
||||
"staged": str(fresh_dir / "new.safetensors"),
|
||||
"original": str(nested / "new.safetensors"),
|
||||
"restored": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
service = await PendingDeleteService.get_instance()
|
||||
assert service._known_batch_dirs == {}
|
||||
|
||||
# Registry-only default: the externally created (crash-leftover) batches
|
||||
# are invisible.
|
||||
await service.purge_expired()
|
||||
assert expired_dir.is_dir()
|
||||
assert fresh_dir.is_dir()
|
||||
|
||||
# Reconciliation pass (startup sweep): expired purged, fresh registered.
|
||||
await service.purge_expired(scan_roots=True)
|
||||
|
||||
assert not expired_dir.exists()
|
||||
assert not (nested / "old.safetensors").exists()
|
||||
assert fresh_dir.is_dir()
|
||||
assert (fresh_dir / "new.safetensors").exists()
|
||||
assert "crash-expired" not in service._known_batch_dirs
|
||||
assert service._known_batch_dirs.get("crash-fresh") == str(fresh_dir)
|
||||
|
||||
|
||||
# (d) FOLDER-DELETED EDGE: the model's whole folder is deleted during the undo
|
||||
# window (the batch lived inside it - accepted edge). undo() must surface
|
||||
# ValueError (batch gone) without crashing and forget the stale registry
|
||||
# entry.
|
||||
async def test_symlink4_folder_deleted_edge_forgets_stale_registry(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
service = await PendingDeleteService.get_instance()
|
||||
batch_id = await _stage_simple(service, root, "model")
|
||||
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
|
||||
assert batch_id in service._known_batch_dirs
|
||||
|
||||
# The model's folder (and with it the sibling batch dir) vanishes.
|
||||
shutil.rmtree(root)
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown batch"):
|
||||
await service.undo(batch_id)
|
||||
|
||||
# No stale registry entry survives the failed undo.
|
||||
assert batch_id not in service._known_batch_dirs
|
||||
assert not batch_dir.exists()
|
||||
|
||||
|
||||
# (e) MERGE EXDEV-ABORT: a cross-volume merge abort leaves the registry
|
||||
# untouched AND the constituent batches individually undoable - sequential
|
||||
# undo after the abort restores every file. (The merge-success winner/loser
|
||||
# registry half is covered by test_reg_e; this adds the post-abort undo
|
||||
# proof.)
|
||||
async def test_symlink5_merge_exdev_abort_registry_unchanged_then_sequential_undo(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
_spy_purge_timers(monkeypatch)
|
||||
service = await PendingDeleteService.get_instance()
|
||||
bid_a = await _stage_simple(service, root, "alpha")
|
||||
bid_b = await _stage_simple(service, root, "beta")
|
||||
assert set(service._known_batch_dirs) == {bid_a, bid_b}
|
||||
|
||||
real_rename = os.rename
|
||||
fail_next = {"enabled": True}
|
||||
|
||||
def exdev_rename(src: str, dst: str) -> None:
|
||||
if fail_next["enabled"]:
|
||||
raise OSError(errno.EXDEV, "Invalid cross-device link", src, dst)
|
||||
return real_rename(src, dst)
|
||||
|
||||
monkeypatch.setattr("py.services.pending_delete_service.os.rename", exdev_rename)
|
||||
|
||||
before = dict(service._known_batch_dirs)
|
||||
assert await service.merge_batches([bid_a, bid_b]) is None
|
||||
assert dict(service._known_batch_dirs) == before
|
||||
|
||||
# Sequential undo of the constituents after the abort restores everything.
|
||||
fail_next["enabled"] = False
|
||||
await service.undo(bid_a)
|
||||
await service.undo(bid_b)
|
||||
assert (root / "alpha.safetensors").read_bytes() == b"alpha-data"
|
||||
assert (root / "beta.safetensors").read_bytes() == b"beta-data"
|
||||
staging = root / PENDING_DELETE_DIR_NAME
|
||||
assert not staging.exists() or not any(staging.iterdir())
|
||||
|
||||
@@ -31,7 +31,6 @@ from py.services.recipes.persistence_service import (
|
||||
PersistenceResult,
|
||||
RecipePersistenceService,
|
||||
)
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
from py.utils import settings_paths
|
||||
|
||||
|
||||
@@ -223,29 +222,6 @@ async def test_delete_recipe_skips_missing_preview_image(tmp_path: Path) -> None
|
||||
assert scanner.removed == ["r2"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (3) undo disabled -> no staging, payload batch_id None, existing behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
async def test_delete_recipe_undo_disabled_no_staging(tmp_path: Path) -> None:
|
||||
get_settings_manager().settings["delete_undo_enabled"] = False
|
||||
|
||||
scanner = RecipeScannerStub(tmp_path)
|
||||
json_path, image_path, _recipe_data = _write_recipe(tmp_path, "r3")
|
||||
scanner.register_recipe("r3", json_path)
|
||||
|
||||
result = await _make_service().delete_recipe(
|
||||
recipe_scanner=scanner, recipe_id="r3"
|
||||
)
|
||||
|
||||
assert result.payload["batch_id"] is None
|
||||
# No staging leftovers when undo is disabled.
|
||||
assert not _staging_parent().exists()
|
||||
# Existing hard delete behavior unchanged.
|
||||
assert not json_path.exists()
|
||||
assert not image_path.exists()
|
||||
assert scanner.removed == ["r3"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (4) bulk_delete with 2 ids -> single batch_id, one batch dir with both
|
||||
# recipes, re-anchored expires_at in the merged manifest
|
||||
|
||||
@@ -1178,8 +1178,3 @@ def test_skip_previously_downloaded_model_versions_coerces_string_input(manager)
|
||||
|
||||
assert manager.get_skip_previously_downloaded_model_versions() is True
|
||||
assert manager.settings["skip_previously_downloaded_model_versions"] is True
|
||||
|
||||
|
||||
def test_delete_undo_enabled_defaults_true(manager):
|
||||
assert settings_manager_module.DEFAULT_SETTINGS.get("delete_undo_enabled") is True
|
||||
assert manager.get("delete_undo_enabled") is True
|
||||
|
||||
Reference in New Issue
Block a user