Compare commits

...
7 Commits
Author SHA1 Message Date
Will Miao 5c2b2aedcc fix(i18n): complete recipe delete undo warning translations
Translate modals.deleteRecipe.recoverableWarning in all 9 non-English
locales (de, es, fr, he, ja, ko, ru, zh-CN, zh-TW)
2026-08-11 21:20:54 +08:00
Will Miao ebc31fb963 fix(i18n): translate recipe delete undo warning
Move the recipe delete modal's undo warning into modals.deleteRecipe.
recoverableWarning instead of hardcoded English; sync placeholders into
all 9 non-English locales
2026-08-11 21:18:36 +08:00
Will Miao 9659df6ad9 refactor(delete): make undo unconditional, remove undo toggle and button delay
- Remove delete_undo_enabled setting (backend default, frontend state,
  settings modal UI, 10 locales); staged deletes with 30s undo are now
  the only delete path and stale settings keys are silently ignored
- Remove the 1500ms delete-button arm delay (armDeleteButton) from all
  delete modals; misclicks are recoverable via the undo toast
- Delete modal always shows the recoverable warning
- Log the first staged file path in staging log lines for easier support
2026-08-11 21:15:59 +08:00
Will Miao 04d131e9dc docs(delete): correct same-volume guarantee after symlink fix 2026-08-11 19:01:44 +08:00
Will Miao 78fe6282c7 test(delete): symlink and restart regression for staged deletes 2026-08-11 19:00:28 +08:00
Will Miao 0c00ee22fc fix(delete): stage model deletes into the model folder (avoid EXDEV) 2026-08-11 18:48:03 +08:00
Will Miao 5fd4946b1f fix(delete): track staged batches in-process; reconcile at startup 2026-08-11 18:36:30 +08:00
40 changed files with 1466 additions and 929 deletions
+202
View File
@@ -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
View File
@@ -443,7 +443,6 @@
"label": "Bereits heruntergeladene Modellversionen überspringen", "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." "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": { "layoutSettings": {
"groupByModel": "Nach Modell gruppieren", "groupByModel": "Nach Modell gruppieren",
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.", "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}", "freesSpace": "[TODO: Translate] Frees {size}",
"title": "Modell löschen", "title": "Modell löschen",
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?", "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." "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": { "excludeModel": {
"title": "Modell ausschließen", "title": "Modell ausschließen",
"message": "Sind Sie sicher, dass Sie dieses Modell ausschließen möchten? Ausgeschlossene Modelle erscheinen nicht in Suchergebnissen oder Modelllisten." "message": "Sind Sie sicher, dass Sie dieses Modell ausschließen möchten? Ausgeschlossene Modelle erscheinen nicht in Suchergebnissen oder Modelllisten."
+3 -2
View File
@@ -443,7 +443,6 @@
"label": "Skip previously downloaded model versions", "label": "Skip previously downloaded model versions",
"help": "When enabled, versions downloaded before will be skipped." "help": "When enabled, versions downloaded before will be skipped."
}, },
"deleteUndoEnabled": "Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": { "layoutSettings": {
"groupByModel": "Group by Model", "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.", "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}", "freesSpace": "Frees {size}",
"title": "Delete Model", "title": "Delete Model",
"message": "Are you sure you want to delete this model and all associated files?", "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." "recoverableWarning": "This will permanently delete the file after 30 seconds unless you undo."
}, },
"deleteRecipe": {
"recoverableWarning": "This action can be undone for 30 seconds."
},
"excludeModel": { "excludeModel": {
"title": "Exclude Model", "title": "Exclude Model",
"message": "Are you sure you want to exclude this model? Excluded models won't appear in searches or model lists." "message": "Are you sure you want to exclude this model? Excluded models won't appear in searches or model lists."
+3 -2
View File
@@ -443,7 +443,6 @@
"label": "Omitir versiones de modelos previamente descargadas", "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." "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": { "layoutSettings": {
"groupByModel": "Agrupar por modelo", "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.", "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}", "freesSpace": "[TODO: Translate] Frees {size}",
"title": "Eliminar modelo", "title": "Eliminar modelo",
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?", "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." "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": { "excludeModel": {
"title": "Excluir modelo", "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." "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
View File
@@ -443,7 +443,6 @@
"label": "Ignorer les versions de modèles précédemment téléchargées", "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." "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": { "layoutSettings": {
"groupByModel": "Grouper par modèle", "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.", "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}", "freesSpace": "[TODO: Translate] Frees {size}",
"title": "Supprimer le modèle", "title": "Supprimer le modèle",
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?", "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." "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": { "excludeModel": {
"title": "Exclure le modèle", "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." "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
View File
@@ -443,7 +443,6 @@
"label": "דלג על גרסאות מודלים שהורדו בעבר", "label": "דלג על גרסאות מודלים שהורדו בעבר",
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה." "help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
}, },
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": { "layoutSettings": {
"groupByModel": "קיבוץ לפי דגם", "groupByModel": "קיבוץ לפי דגם",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.", "groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
@@ -1281,9 +1280,11 @@
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "[TODO: Translate] Frees {size}",
"title": "מחק מודל", "title": "מחק מודל",
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?", "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." "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
}, },
"deleteRecipe": {
"recoverableWarning": "ניתן לבטל פעולה זו תוך 30 שניות."
},
"excludeModel": { "excludeModel": {
"title": "החרג מודל", "title": "החרג מודל",
"message": "האם אתה בטוח שברצונך להחריג מודל זה? מודלים מוחרגים לא יופיעו בחיפושים או ברשימות מודלים." "message": "האם אתה בטוח שברצונך להחריג מודל זה? מודלים מוחרגים לא יופיעו בחיפושים או ברשימות מודלים."
+3 -2
View File
@@ -443,7 +443,6 @@
"label": "以前にダウンロードしたモデルバージョンをスキップ", "label": "以前にダウンロードしたモデルバージョンをスキップ",
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。" "help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
}, },
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": { "layoutSettings": {
"groupByModel": "モデルでグループ化", "groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。", "groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
@@ -1281,9 +1280,11 @@
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "[TODO: Translate] Frees {size}",
"title": "モデルを削除", "title": "モデルを削除",
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?", "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." "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
}, },
"deleteRecipe": {
"recoverableWarning": "この操作は30秒以内であれば元に戻せます。"
},
"excludeModel": { "excludeModel": {
"title": "モデルを除外", "title": "モデルを除外",
"message": "このモデルを除外してもよろしいですか?除外されたモデルは検索やモデルリストに表示されません。" "message": "このモデルを除外してもよろしいですか?除外されたモデルは検索やモデルリストに表示されません。"
+3 -2
View File
@@ -443,7 +443,6 @@
"label": "이전에 다운로드한 모델 버전 건너뛰기", "label": "이전에 다운로드한 모델 버전 건너뛰기",
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다." "help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
}, },
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": { "layoutSettings": {
"groupByModel": "모델별 그룹화", "groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.", "groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
@@ -1281,9 +1280,11 @@
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "[TODO: Translate] Frees {size}",
"title": "모델 삭제", "title": "모델 삭제",
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?", "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." "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
}, },
"deleteRecipe": {
"recoverableWarning": "이 작업은 30초 이내에 실행 취소할 수 있습니다."
},
"excludeModel": { "excludeModel": {
"title": "모델 제외", "title": "모델 제외",
"message": "이 모델을 제외하시겠습니까? 제외된 모델은 검색이나 모델 목록에 나타나지 않습니다." "message": "이 모델을 제외하시겠습니까? 제외된 모델은 검색이나 모델 목록에 나타나지 않습니다."
+3 -2
View File
@@ -443,7 +443,6 @@
"label": "Пропускать ранее загруженные версии моделей", "label": "Пропускать ранее загруженные версии моделей",
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки." "help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
}, },
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": { "layoutSettings": {
"groupByModel": "Группировать по модели", "groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.", "groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
@@ -1281,9 +1280,11 @@
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "[TODO: Translate] Frees {size}",
"title": "Удалить модель", "title": "Удалить модель",
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?", "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." "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
}, },
"deleteRecipe": {
"recoverableWarning": "Это действие можно отменить в течение 30 секунд."
},
"excludeModel": { "excludeModel": {
"title": "Исключить модель", "title": "Исключить модель",
"message": "Вы уверены, что хотите исключить эту модель? Исключенные модели не будут отображаться в поиске или списках моделей." "message": "Вы уверены, что хотите исключить эту модель? Исключенные модели не будут отображаться в поиске или списках моделей."
+3 -2
View File
@@ -443,7 +443,6 @@
"label": "跳过已下载的模型版本", "label": "跳过已下载的模型版本",
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。" "help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
}, },
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": { "layoutSettings": {
"groupByModel": "按模型分组", "groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。", "groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
@@ -1281,9 +1280,11 @@
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "[TODO: Translate] Frees {size}",
"title": "删除模型", "title": "删除模型",
"message": "你确定要删除此模型及所有相关文件吗?", "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." "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
}, },
"deleteRecipe": {
"recoverableWarning": "此操作可在 30 秒内撤销。"
},
"excludeModel": { "excludeModel": {
"title": "排除模型", "title": "排除模型",
"message": "你确定要排除此模型吗?被排除的模型不会出现在搜索或模型列表中。" "message": "你确定要排除此模型吗?被排除的模型不会出现在搜索或模型列表中。"
+3 -2
View File
@@ -443,7 +443,6 @@
"label": "跳過已下載的模型版本", "label": "跳過已下載的模型版本",
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。" "help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
}, },
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": { "layoutSettings": {
"groupByModel": "按模型分組", "groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。", "groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
@@ -1281,9 +1280,11 @@
"freesSpace": "[TODO: Translate] Frees {size}", "freesSpace": "[TODO: Translate] Frees {size}",
"title": "刪除模型", "title": "刪除模型",
"message": "您確定要刪除此模型及所有相關檔案嗎?", "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." "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
}, },
"deleteRecipe": {
"recoverableWarning": "此操作可在 30 秒內復原。"
},
"excludeModel": { "excludeModel": {
"title": "排除模型", "title": "排除模型",
"message": "您確定要排除此模型嗎?被排除的模型將不會出現在搜尋或模型列表中。" "message": "您確定要排除此模型嗎?被排除的模型將不會出現在搜尋或模型列表中。"
+6 -3
View File
@@ -251,11 +251,14 @@ class LoraManager:
# Startup sweep: purge pending-delete batches that expired during a # Startup sweep: purge pending-delete batches that expired during a
# previous run. Non-blocking (fire-and-forget); purge_expired only # previous run. Non-blocking (fire-and-forget); purge_expired only
# removes already-expired batches, so a staged undo that survived a # removes already-expired batches, so a staged undo that survived a
# restart stays restorable. Covers both plugin and standalone modes # restart stays restorable. scan_roots=True runs the reconciliation
# (StandaloneLoraManager reuses this classmethod). # 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() pending_delete_service = await get_pending_delete_service()
asyncio.create_task( asyncio.create_task(
pending_delete_service.purge_expired(), pending_delete_service.purge_expired(scan_roots=True),
name="pending_delete_startup_sweep", name="pending_delete_startup_sweep",
) )
+215 -63
View File
@@ -39,14 +39,13 @@ from typing import (
from ..utils.constants import PREVIEW_EXTENSIONS from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils import settings_paths from ..utils import settings_paths
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Undo window in seconds before a staged batch becomes purge-eligible. # Undo window in seconds before a staged batch becomes purge-eligible.
PENDING_DELETE_TTL_SECONDS = 30 PENDING_DELETE_TTL_SECONDS = 30
# Hidden staging directory name placed under each model root (and the settings # Hidden staging directory name placed inside each deleted model's own folder
# dir for recipes). # (sibling of the model artifacts) and under the settings dir for recipes.
PENDING_DELETE_DIR_NAME = ".lm-pending-delete" PENDING_DELETE_DIR_NAME = ".lm-pending-delete"
# Manifest file name inside every batch directory. # Manifest file name inside every batch directory.
MANIFEST_FILE_NAME = "manifest.json" MANIFEST_FILE_NAME = "manifest.json"
@@ -97,6 +96,11 @@ class PendingDeleteService:
# ServiceRegistry roots during sweeps so undo/purge work even before # ServiceRegistry roots during sweeps so undo/purge work even before
# every scanner is registered. # every scanner is registered.
self._known_roots: List[str] = [] 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 # Public API
@@ -111,19 +115,19 @@ class PendingDeleteService:
original_file_path: str, original_file_path: str,
cached_entry: Optional[Dict[str, Any]], cached_entry: Optional[Dict[str, Any]],
) -> Optional[str]: ) -> 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 The batch dir is created inside the model file's OWN directory
root cannot be resolved, or staging failed (caller falls back to a (``target_dir``), so staging/undo renames stay within one real
hard delete). 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 # LOCK-FREE section: opportunistic purge must never run while holding
# the ops lock (the lock is not re-entrant). # the ops lock (the lock is not re-entrant).
await self._opportunistic_purge() await self._opportunistic_purge()
if not self._undo_enabled():
return None
async with self._ops_lock: async with self._ops_lock:
batch_dir: Optional[str] = None batch_dir: Optional[str] = None
staged_pairs: List[Dict[str, Any]] = [] staged_pairs: List[Dict[str, Any]] = []
@@ -148,7 +152,7 @@ class PendingDeleteService:
batch_id = self._new_batch_id() batch_id = self._new_batch_id()
batch_dir = os.path.join( 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) os.makedirs(batch_dir, exist_ok=True)
@@ -176,14 +180,16 @@ class PendingDeleteService:
) )
self._write_manifest_atomic(batch_dir, manifest) self._write_manifest_atomic(batch_dir, manifest)
self._remember_root(root) self._remember_root(root)
await self._remember_batch(batch_id, batch_dir)
# Arm the per-batch purge timer. Safe inside the lock: task # Arm the per-batch purge timer. Safe inside the lock: task
# creation does not await, and purge_batch re-reads the # creation does not await, and purge_batch re-reads the
# manifest's expires_at at fire time, so stale timers no-op. # manifest's expires_at at fire time, so stale timers no-op.
self._arm_purge_timer(batch_id) self._arm_purge_timer(batch_id)
logger.info( logger.info(
"Staged model delete batch %s with %d file(s)", "Staged model delete batch %s with %d file(s): %s",
batch_id, batch_id,
len(staged_pairs), len(staged_pairs),
staged_pairs[0]["original"] if staged_pairs else None,
) )
return batch_id return batch_id
except OSError as exc: except OSError as exc:
@@ -209,14 +215,11 @@ class PendingDeleteService:
) -> Optional[str]: ) -> Optional[str]:
"""Copy a recipe JSON (and, when it exists, its image) into staging. """Copy a recipe JSON (and, when it exists, its image) into staging.
Returns the batch id, or ``None`` when undo is disabled / staging Returns the batch id, or ``None`` when staging failed. Missing or
failed. Missing or shared preview images are skipped. shared preview images are skipped.
""" """
await self._opportunistic_purge() await self._opportunistic_purge()
if not self._undo_enabled():
return None
async with self._ops_lock: async with self._ops_lock:
batch_dir: Optional[str] = None batch_dir: Optional[str] = None
staged_pairs: List[Dict[str, Any]] = [] staged_pairs: List[Dict[str, Any]] = []
@@ -246,9 +249,10 @@ class PendingDeleteService:
self._write_manifest_atomic(batch_dir, manifest) self._write_manifest_atomic(batch_dir, manifest)
self._arm_purge_timer(batch_id) self._arm_purge_timer(batch_id)
logger.info( logger.info(
"Staged recipe delete batch %s with %d file(s)", "Staged recipe delete batch %s with %d file(s): %s",
batch_id, batch_id,
len(staged_pairs), len(staged_pairs),
staged_pairs[0]["original"] if staged_pairs else None,
) )
return batch_id return batch_id
except OSError as exc: except OSError as exc:
@@ -296,7 +300,7 @@ class PendingDeleteService:
# Track (entry, original_staged_path, loser_dir) for rollback. # Track (entry, original_staged_path, loser_dir) for rollback.
moved: List[Tuple[Dict[str, Any], str, str]] = [] moved: List[Tuple[Dict[str, Any], str, str]] = []
processed_losers: List[str] = [] processed_losers: List[Tuple[str, str]] = [] # (loser_id, loser_dir)
try: try:
for loser_id in batch_ids[1:]: for loser_id in batch_ids[1:]:
@@ -332,7 +336,7 @@ class PendingDeleteService:
entry["staged"] = os.path.abspath(new_staged) entry["staged"] = os.path.abspath(new_staged)
winner_manifest["entries"].append(entry) winner_manifest["entries"].append(entry)
moved.append((entry, original_staged, loser_dir)) moved.append((entry, original_staged, loser_dir))
processed_losers.append(loser_dir) processed_losers.append((loser_id, loser_dir))
except OSError as exc: except OSError as exc:
logger.warning( logger.warning(
"Merge of %s failed after moving files: %s; rolling back", "Merge of %s failed after moving files: %s; rolling back",
@@ -357,10 +361,15 @@ class PendingDeleteService:
self._rollback_merge_moves(moved) self._rollback_merge_moves(moved)
return None return None
# All moves committed: remove loser dirs (must be empty by now). # All moves committed: remove loser dirs (must be empty by now)
for loser_dir in processed_losers: # 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_manifest(loser_dir)
self._remove_empty_dir(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 # Arm a fresh purge timer for the winner with the re-anchored
# expiry (the winner's original timer fires at the OLD expiry and # 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. # Remove the manifest + batch dir only after all entries restored.
self._remove_manifest(batch_dir) self._remove_manifest(batch_dir)
self._remove_empty_dir(batch_dir) self._remove_empty_dir(batch_dir)
await self._forget_batch(batch_id)
logger.info("Restored pending-delete batch %s", batch_id) logger.info("Restored pending-delete batch %s", batch_id)
return self._undo_result(manifest) return self._undo_result(manifest)
async def purge_expired(self) -> int: async def purge_expired(self, scan_roots: bool = False) -> int:
"""Purge every expired batch across ALL model roots and the recipe dir. """Purge every expired batch.
Lock-free by design: enumerates staging parents (all scanner types via Default (registry-only): iterates a SNAPSHOT of the in-process MODEL
the ServiceRegistry plus the global recipe staging dir) and delegates batch registry plus a shallow check of the fixed recipe staging
each batch to :meth:`purge_batch`, which acquires the ops lock. Never parent - cheap, no tree walk per delete. With ``scan_roots=True``
call this while holding the ops lock. (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 purged = 0
for parent in await self._get_all_staging_parents(): if scan_roots:
if not os.path.isdir(parent): 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 continue
for name in self._list_dir_names(parent): if name not in batch_ids:
if name.endswith(ORPHANED_SUFFIX): batch_ids.append(name)
# Quarantine is terminal - never re-rename or delete. for batch_id in batch_ids:
continue try:
try: await self.purge_batch(batch_id)
await self.purge_batch(name) purged += 1
purged += 1 except Exception as exc: # defensive - sweep must not crash
except Exception as exc: # defensive - sweep must not crash logger.warning("Failed to purge batch %s: %s", batch_id, exc)
logger.warning("Failed to purge batch %s: %s", name, exc)
return purged 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: async def purge_batch(self, batch_id: str) -> None:
"""Purge one batch. Silent no-op for missing/undone/not-yet-expired. """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) batch_dir = await self._find_batch_dir(batch_id)
if not batch_dir: if not batch_dir:
return 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 # Internals
@@ -488,18 +568,34 @@ class PendingDeleteService:
except Exception as exc: # defensive - staging/undo must still proceed except Exception as exc: # defensive - staging/undo must still proceed
logger.warning("Opportunistic pending-delete purge failed: %s", exc) 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: def _remember_root(self, root: str) -> None:
"""Record a root the service has staged into (in-process registry).""" """Record a root the service has staged into (in-process registry)."""
if root and root not in self._known_roots: if root and root not in self._known_roots:
self._known_roots.append(root) 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]: def _find_model_root(self, scanner: Any, original_file_path: Optional[str]) -> Optional[str]:
"""Return the configured root containing ``original_file_path``.""" """Return the configured root containing ``original_file_path``."""
finder = getattr(scanner, "_find_root_for_file", None) 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 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]: async def _get_all_model_roots(self) -> List[str]:
"""Collect every configured model root across all scanner types. """Collect every configured model root across all scanner types.
@@ -878,13 +962,81 @@ class PendingDeleteService:
return roots return roots
async def _find_batch_dir(self, batch_id: str) -> Optional[str]: 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: if not batch_id:
return None return None
for parent in await self._get_all_staging_parents(): # 1) Registry fast path.
candidate = os.path.join(parent, batch_id) async with self._registry_lock:
if os.path.isdir(candidate): 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 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 return None
def _list_dir_names(self, parent: str) -> List[str]: def _list_dir_names(self, parent: str) -> List[str]:
-1
View File
@@ -111,7 +111,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"backup_retention_count": 5, "backup_retention_count": 5,
"use_new_license_icons": True, "use_new_license_icons": True,
"group_by_model": False, "group_by_model": False,
"delete_undo_enabled": True,
# AI / LLM provider configuration (BYOK) # AI / LLM provider configuration (BYOK)
"llm_provider": "openai", # "openai" | "ollama" | "custom" "llm_provider": "openai", # "openai" | "ollama" | "custom"
"llm_api_key": "", "llm_api_key": "",
+1 -1
View File
@@ -203,7 +203,7 @@ export class BaseModelApiClient {
} }
const batchId = data.batch_id || null; const batchId = data.batch_id || null;
if (!batchId) { 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. // When staged, the caller shows the undo action toast instead.
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success'); showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
} }
@@ -1,7 +1,6 @@
// Duplicates Manager Component // Duplicates Manager Component
import { showToast, showActionToast } from '../utils/uiHelpers.js'; import { showToast, showActionToast } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js'; import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { RecipeCard } from './RecipeCard.js'; import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
@@ -449,7 +448,6 @@ export class DuplicatesManager {
// Use the modal manager to show the confirmation modal // Use the modal manager to show the confirmation modal
modalManager.showModal('duplicateDeleteModal'); modalManager.showModal('duplicateDeleteModal');
armDeleteButton(document.getElementById('duplicateDeleteModal'));
} catch (error) { } catch (error) {
console.error('Error preparing delete:', error); console.error('Error preparing delete:', error);
showToast('toast.duplicates.deleteError', { message: error.message }, 'error'); showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
@@ -1,7 +1,6 @@
// Model Duplicates Manager Component for LoRAs and Checkpoints // Model Duplicates Manager Component for LoRAs and Checkpoints
import { showToast, showActionToast } from '../utils/uiHelpers.js'; import { showToast, showActionToast } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js'; import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { formatDate } from '../utils/formatters.js'; import { formatDate } from '../utils/formatters.js';
@@ -703,7 +702,6 @@ export class ModelDuplicatesManager {
// Use the modal manager to show the confirmation modal // Use the modal manager to show the confirmation modal
modalManager.showModal('modelDuplicateDeleteModal'); modalManager.showModal('modelDuplicateDeleteModal');
armDeleteButton(document.getElementById('modelDuplicateDeleteModal'));
} catch (error) { } catch (error) {
console.error('Error preparing delete:', error); console.error('Error preparing delete:', error);
showToast('toast.duplicates.deleteError', { message: error.message }, 'error'); showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
+1 -9
View File
@@ -9,7 +9,6 @@ import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js'; import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js'; import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
class RecipeCard { class RecipeCard {
constructor(recipe, clickHandler) { constructor(recipe, clickHandler) {
@@ -366,7 +365,7 @@ class RecipeCard {
</div> </div>
<div class="delete-info"> <div class="delete-info">
<h3>${this.recipe.title}</h3> <h3>${this.recipe.title}</h3>
<p>This action cannot be undone.</p> <p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
</div> </div>
</div> </div>
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p> <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 // Show the modal with custom content and setup callbacks
let deleteArmTimer = null;
modalManager.showModal('deleteModal', deleteModalContent, () => { modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback // This is the onClose callback
if (deleteArmTimer) {
clearTimeout(deleteArmTimer);
deleteArmTimer = null;
}
const deleteModal = document.getElementById('deleteModal'); const deleteModal = document.getElementById('deleteModal');
const deleteBtn = deleteModal.querySelector('.delete-btn'); const deleteBtn = deleteModal.querySelector('.delete-btn');
deleteBtn.textContent = 'Delete'; deleteBtn.textContent = 'Delete';
@@ -404,8 +398,6 @@ class RecipeCard {
cancelBtn.onclick = () => modalManager.closeModal('deleteModal'); cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => this.confirmDeleteRecipe(); deleteBtn.onclick = () => this.confirmDeleteRecipe();
deleteArmTimer = armDeleteButton(deleteModal);
} catch (error) { } catch (error) {
console.error('Error showing delete confirmation:', error); console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error'); showToast('toast.recipes.deleteConfirmationError', {}, 'error');
-2
View File
@@ -1,7 +1,6 @@
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js'; import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js'; import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -630,7 +629,6 @@ export class BulkManager {
} }
modalManager.showModal('bulkDeleteModal'); modalManager.showModal('bulkDeleteModal');
armDeleteButton(document.getElementById('bulkDeleteModal'));
} }
async confirmBulkDelete() { async confirmBulkDelete() {
-6
View File
@@ -1111,12 +1111,6 @@ export class SettingsManager {
includeTriggerWordsCheckbox.checked = state.global.settings.include_trigger_words || false; 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 // Set lora syntax format
const loraSyntaxFormatSelect = document.getElementById('loraSyntaxFormat'); const loraSyntaxFormatSelect = document.getElementById('loraSyntaxFormat');
if (loraSyntaxFormatSelect) { if (loraSyntaxFormatSelect) {
-1
View File
@@ -58,7 +58,6 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
strip_lora_on_copy: false, strip_lora_on_copy: false,
use_new_license_icons: true, use_new_license_icons: true,
group_by_model: false, group_by_model: false,
delete_undo_enabled: true,
llm_provider: 'openai', llm_provider: 'openai',
llm_api_key: '', llm_api_key: '',
llm_api_base: '', llm_api_base: '',
+1 -30
View File
@@ -3,31 +3,11 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { showActionToast } from './uiHelpers.js'; import { showActionToast } from './uiHelpers.js';
import { translate } from './i18nHelpers.js'; import { translate } from './i18nHelpers.js';
import { handleUndoDelete } from './undoHelpers.js'; import { handleUndoDelete } from './undoHelpers.js';
import { state } from '../state/index.js';
import { formatFileSize } from '../components/shared/utils.js'; import { formatFileSize } from '../components/shared/utils.js';
const DELETE_BUTTON_ARM_DELAY_MS = 1500;
let pendingDeletePath = null; let pendingDeletePath = null;
let pendingDeleteName = null; let pendingDeleteName = null;
let pendingExcludePath = 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) { export function showDeleteModal(filePath) {
pendingDeletePath = filePath; pendingDeletePath = filePath;
@@ -41,10 +21,6 @@ export function showDeleteModal(filePath) {
const modal = modalManager.getModal('deleteModal').element; const modal = modalManager.getModal('deleteModal').element;
const modelInfo = modal.querySelector('.delete-model-info'); 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 fileSize = card?.dataset.file_size;
const sizeLine = fileSize const sizeLine = fileSize
? `<br>${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}` ? `<br>${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}`
@@ -55,11 +31,10 @@ export function showDeleteModal(filePath) {
<br> <br>
<strong>File:</strong> ${filePath} <strong>File:</strong> ${filePath}
<br> <br>
${translate(warningKey)}${sizeLine} ${translate('modals.deleteModel.recoverableWarning')}${sizeLine}
`; `;
modalManager.showModal('deleteModal'); modalManager.showModal('deleteModal');
pendingDeleteArmTimer = armDeleteButton(modal);
} }
export async function confirmDelete() { export async function confirmDelete() {
@@ -90,10 +65,6 @@ export async function confirmDelete() {
export function closeDeleteModal() { export function closeDeleteModal() {
modalManager.closeModal('deleteModal'); modalManager.closeModal('deleteModal');
if (pendingDeleteArmTimer) {
clearTimeout(pendingDeleteArmTimer);
pendingDeleteArmTimer = null;
}
pendingDeletePath = null; pendingDeletePath = null;
pendingDeleteName = null; pendingDeleteName = null;
} }
@@ -811,22 +811,6 @@
</div> </div>
</div> </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>
</div> </div>
@@ -123,7 +123,6 @@ vi.mock('../../../static/js/state/index.js', () => ({
vi.mock('../../../static/js/utils/modalUtils.js', () => ({ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
showExcludeModal: vi.fn(), showExcludeModal: vi.fn(),
showDeleteModal: vi.fn(), showDeleteModal: vi.fn(),
armDeleteButton: vi.fn(),
})); }));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({ 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', () => ({ 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/infiniteScroll.js', () => ({ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
recreateVirtualScroll: recreateVirtualScrollMock, 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, resetAndReload: resetAndReloadMock,
})); }));
vi.mock('../../../static/js/utils/modalUtils.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);
},
}));
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js'); const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
const { state } = await import('../../../static/js/state/index.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(); 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, closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock, confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock, closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
})); }));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({ vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
@@ -26,7 +26,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock, closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock, confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock, closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
})); }));
vi.mock('../../../static/js/api/apiConfig.js', () => ({ vi.mock('../../../static/js/api/apiConfig.js', () => ({
-1
View File
@@ -36,7 +36,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock, closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock, confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock, closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
})); }));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({ vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
-11
View File
@@ -66,9 +66,6 @@ describe('translate() with real en.json locale', () => {
it('resolves delete-model modal keys with interpolation', () => { it('resolves delete-model modal keys with interpolation', () => {
installWindowI18n(); installWindowI18n();
expect(translate('modals.deleteModel.permanentWarning')).toBe(
'This will permanently delete the file from disk.',
);
expect(translate('modals.deleteModel.recoverableWarning')).toBe( expect(translate('modals.deleteModel.recoverableWarning')).toBe(
'This will permanently delete the file after 30 seconds unless you undo.', '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)', () => { it('returns the raw key when no translation exists (fallback contract)', () => {
installWindowI18n(); installWindowI18n();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+2 -102
View File
@@ -1,4 +1,4 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; import { describe, it, beforeEach, expect, vi } from 'vitest';
const { const {
MODAL_UTILS_MODULE, MODAL_UTILS_MODULE,
@@ -7,7 +7,6 @@ const {
UI_HELPERS_MODULE, UI_HELPERS_MODULE,
I18N_MODULE, I18N_MODULE,
UNDO_HELPERS_MODULE, UNDO_HELPERS_MODULE,
STATE_MODULE,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
MODAL_UTILS_MODULE: new URL('../../../static/js/utils/modalUtils.js', import.meta.url).pathname, 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, 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, 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, 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, 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(); 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', () => { describe('modalUtils showDeleteModal warning copy and size line', () => {
beforeEach(() => { beforeEach(() => {
showModalMock.mockReset(); 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() { function modelInfoHtml() {
return document.querySelector('#deleteModal .delete-model-info').innerHTML; return document.querySelector('#deleteModal .delete-model-info').innerHTML;
} }
it('shows the recoverable warning when delete_undo_enabled is truthy', async () => { it('always shows the recoverable warning', async () => {
const { state } = await import(STATE_MODULE);
state.global.settings.delete_undo_enabled = true;
const { showDeleteModal } = await import(MODAL_UTILS_MODULE); const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors'); showDeleteModal('/models/foo.safetensors');
@@ -240,27 +161,6 @@ describe('modalUtils showDeleteModal warning copy and size line', () => {
expect(modelInfoHtml()).not.toContain('modals.deleteModel.permanentWarning'); 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 () => { it('appends a formatted "Frees {size}" line when the card carries a file size', async () => {
translateMock.mockImplementation((key, params) => translateMock.mockImplementation((key, params) =>
params && params.size ? `${key} ${params.size}` : key params && params.size ? `${key} ${params.size}` : key
+5 -41
View File
@@ -10,7 +10,6 @@ import pytest
from py.services.model_lifecycle_service import ModelLifecycleService, _require_path_in_library_roots 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.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.metadata_manager import MetadataManager
from py.utils.models import LoraMetadata from py.utils.models import LoraMetadata
@@ -953,11 +952,11 @@ def _make_delete_service(scanner: Any) -> ModelLifecycleService:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_model_stages_file_when_undo_enabled(tmp_path: Path): async def test_delete_model_stages_file(tmp_path: Path):
"""Undo enabled (the default): artifacts are renamed into a """Artifacts are renamed into a ``.lm-pending-delete/<batch_id>/`` staging
``.lm-pending-delete/<batch_id>/`` staging dir under the model root, the dir under the model root, the response carries the batch_id, the cache
response carries the batch_id, the cache entry is removed and the cache entry is removed and the cache is persisted (``_persist_calls`` tracked by
is persisted (``_persist_calls`` tracked by ``ScannerForDelete``).""" ``ScannerForDelete``)."""
root = tmp_path / "loras" root = tmp_path / "loras"
root.mkdir() root.mkdir()
model_path = root / "model.safetensors" 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] 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 @pytest.mark.asyncio
async def test_delete_model_falls_back_when_staging_fails( async def test_delete_model_falls_back_when_staging_fails(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-28
View File
@@ -22,7 +22,6 @@ from py.services.pending_delete_service import (
_reset_pending_delete_service, _reset_pending_delete_service,
) )
from py.services.persistent_model_cache import PersistentModelCache, DEFAULT_LICENSE_FLAGS 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.civitai_utils import build_license_flags
from py.utils.models import BaseModelMetadata 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"] 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 @pytest.mark.asyncio
async def test_bulk_delete_cancelled_after_one_staged_batch_present( async def test_bulk_delete_cancelled_after_one_staged_batch_present(
tmp_path: Path, monkeypatch tmp_path: Path, monkeypatch
+785 -41
View File
@@ -15,10 +15,11 @@ import asyncio
import errno import errno
import json import json
import os import os
import shutil
import time import time
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence from typing import Any, Dict, List, Optional, Sequence, Tuple
import pytest 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_hash_index import ModelHashIndex
from py.services.model_scanner import ModelScanner 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 import settings_paths
from py.utils.models import LoraMetadata 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]) await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance() 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" orphaned = staging / "batch1.orphaned"
assert orphaned.is_dir() 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]) await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance() 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" orphaned = staging / "batch2.orphaned"
assert orphaned.is_dir() 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" 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 # (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()) 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 # (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() 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 assert purged >= 4
for root in (lora_root, ckpt_root, emb_root): 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]) await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance() 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" orphaned = staging / "qbatch.orphaned"
assert orphaned.is_dir() assert orphaned.is_dir()
# Second sweep must NOT re-rename or delete the quarantined 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.is_dir()
assert (orphaned / "model.safetensors").read_bytes() == b"data" assert (orphaned / "model.safetensors").read_bytes() == b"data"
assert not batch_dir.exists() 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, 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()) stage_task = asyncio.create_task(do_stage())
results = await asyncio.gather(purge_task, stage_task, return_exceptions=True) 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")] snap_entries = [e for e in manifest["entries"] if e.get("snapshot")]
assert len(snap_entries) == 2 assert len(snap_entries) == 2
assert {e["snapshot"]["file_path"] for e in snap_entries} == {str(a1), str(b1)} 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())
-24
View File
@@ -31,7 +31,6 @@ from py.services.recipes.persistence_service import (
PersistenceResult, PersistenceResult,
RecipePersistenceService, RecipePersistenceService,
) )
from py.services.settings_manager import get_settings_manager
from py.utils import settings_paths 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"] 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 # (4) bulk_delete with 2 ids -> single batch_id, one batch dir with both
# recipes, re-anchored expires_at in the merged manifest # recipes, re-anchored expires_at in the merged manifest
-5
View File
@@ -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.get_skip_previously_downloaded_model_versions() is True
assert manager.settings["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