mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(recipes): add reconnect remediation paths for missing recipe LoRAs
- Snapshot pre-rematch entry state (reconnectSnapshot) so rematched entries can be undone via the existing restore flow - Bulk missing-LoRA downloads mark unresolvable failures hash-invalid, flipping those entries from download to reconnect candidacy - Recipe modal always offers a reconnect action next to download for missing LoRA entries - Rematch runs collect an opt-in relaxed-matching choice (also reconnect missing models by file name) via a pre-run options dialog on the global, bulk and single-recipe entries - L4 (filename-level) matches are listed in a results dialog with per-entry undo
This commit is contained in:
@@ -4012,6 +4012,7 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat
|
||||
"entry": "old.safetensors",
|
||||
"file_name": "m.safetensors",
|
||||
"match_level": "L1",
|
||||
"lora_index": 0,
|
||||
}
|
||||
]
|
||||
assert result["recipe"] is enriched
|
||||
@@ -4032,6 +4033,79 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat
|
||||
assert resort_calls == [] # Metis F1 — hoisted to public entry points
|
||||
|
||||
|
||||
# Rematch write-back must snapshot the pre-match state (undo affordance)
|
||||
|
||||
|
||||
async def test_write_rematch_lora_entry_snapshots_pre_match_state(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
original_entry = {
|
||||
"isDeleted": True,
|
||||
"hashInvalid": False,
|
||||
"hash": "oldhash",
|
||||
"file_name": "old.safetensors",
|
||||
"modelVersionId": 0,
|
||||
"modelName": "Old Name",
|
||||
}
|
||||
entry = dict(original_entry)
|
||||
item = _civitai_lora_item(
|
||||
sha256="b" * 64,
|
||||
version_id=222,
|
||||
name="v2.0",
|
||||
model_name="New Model",
|
||||
file_name="new.safetensors",
|
||||
)
|
||||
|
||||
scanner._write_rematch_lora_entry(entry, item)
|
||||
|
||||
assert entry["hash"] == "b" * 64
|
||||
assert entry["file_name"] == "new.safetensors"
|
||||
assert entry["reconnectSnapshot"] == original_entry
|
||||
|
||||
|
||||
async def test_write_rematch_lora_entry_snapshot_never_nests(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
entry = {
|
||||
"isDeleted": True,
|
||||
"hash": "oldhash",
|
||||
"file_name": "old.safetensors",
|
||||
"reconnectSnapshot": {"file_name": "even-older.safetensors"},
|
||||
}
|
||||
item = _civitai_lora_item(sha256="c" * 64, file_name="new.safetensors")
|
||||
|
||||
scanner._write_rematch_lora_entry(entry, item)
|
||||
|
||||
snapshot = entry["reconnectSnapshot"]
|
||||
assert snapshot["file_name"] == "old.safetensors"
|
||||
assert "reconnectSnapshot" not in snapshot
|
||||
|
||||
|
||||
async def test_write_rematch_checkpoint_entry_snapshots_pre_match_state(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
original_entry = {
|
||||
"isDeleted": True,
|
||||
"hashInvalid": True,
|
||||
"hash": "oldhash",
|
||||
"file_name": "old.safetensors",
|
||||
"name": "Old CP",
|
||||
"modelVersionId": 0,
|
||||
}
|
||||
entry = dict(original_entry)
|
||||
item = _civitai_checkpoint_item(
|
||||
sha256="d" * 64,
|
||||
version_id=333,
|
||||
name="cp-v1",
|
||||
model_name="New CP",
|
||||
file_name="new-cp.safetensors",
|
||||
)
|
||||
|
||||
scanner._write_rematch_checkpoint_entry(entry, item)
|
||||
|
||||
assert entry["hash"] == "d" * 64
|
||||
assert entry["file_name"] == "new-cp.safetensors"
|
||||
assert entry["reconnectSnapshot"] == original_entry
|
||||
assert "reconnectSnapshot" not in entry["reconnectSnapshot"]
|
||||
|
||||
|
||||
# Acceptance criterion (2): checkpoint entry rematched via L2 — parser style
|
||||
|
||||
|
||||
@@ -4659,6 +4733,7 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop(
|
||||
local_cache: dict[str, Any],
|
||||
autov3_cache: dict[str, Any],
|
||||
filename_cache=None,
|
||||
**_kwargs: Any,
|
||||
) -> tuple[int, int, dict[str, Any]]:
|
||||
if recipe.get("id") == "boom":
|
||||
raise RuntimeError("kaboom")
|
||||
@@ -4717,12 +4792,15 @@ async def test_rematch_all_recipes_holds_mutation_lock(tmp_path: Path, monkeypat
|
||||
local_cache: dict[str, Any],
|
||||
autov3_cache: dict[str, Any],
|
||||
filename_cache=None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[int, int, dict[str, Any]]:
|
||||
nonlocal entered
|
||||
if recipe.get("id") == "r0":
|
||||
entered = True
|
||||
await release.wait()
|
||||
return await original(recipe, local_cache, autov3_cache, filename_cache)
|
||||
return await original(
|
||||
recipe, local_cache, autov3_cache, filename_cache, **kwargs
|
||||
)
|
||||
|
||||
monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single)
|
||||
|
||||
@@ -4899,6 +4977,236 @@ async def test_rematch_all_autov3_cache_reuse_across_calls(
|
||||
assert len(called) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Relaxed rematch candidacy (Feature 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_is_rematch_candidate_relaxed_accepts_healthy_entry(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
healthy = {"hash": "abc", "file_name": "m.safetensors"}
|
||||
assert scanner._is_rematch_candidate(healthy, relaxed=True)
|
||||
# Default strict behavior is unchanged.
|
||||
assert not scanner._is_rematch_candidate(healthy)
|
||||
assert not scanner._is_rematch_candidate(healthy, relaxed=False)
|
||||
|
||||
|
||||
async def test_is_rematch_candidate_relaxed_still_requires_identifier(tmp_path: Path):
|
||||
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
|
||||
assert not scanner._is_rematch_candidate({}, relaxed=True)
|
||||
assert not scanner._is_rematch_candidate({"isDeleted": True}, relaxed=True)
|
||||
assert not scanner._is_rematch_candidate("garbage", relaxed=True)
|
||||
|
||||
|
||||
async def test_rematch_relaxed_skips_healthy_entry_with_local_hash(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
# Anti-churn: a relaxed-only candidate whose hash already resolves in the
|
||||
# L1 local cache is already correctly linked — no write-back, no
|
||||
# snapshot, and it counts as neither matched nor unresolved.
|
||||
sha256 = ("A1" * 32).lower()
|
||||
item = _civitai_lora_item(sha256=sha256, file_name="m.safetensors")
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
recipe: Dict[str, Any] = {
|
||||
"id": "r1",
|
||||
"loras": [{"hash": sha256, "file_name": "m.safetensors"}],
|
||||
}
|
||||
_set_recipe_cache(scanner, [recipe])
|
||||
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
|
||||
result = await scanner.rematch_recipe_by_id("r1", relaxed=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["matched_entries"] == 0
|
||||
assert result["unresolved_entries"] == 0
|
||||
assert result["details"] == {"matched": [], "unresolved": []}
|
||||
assert saved == []
|
||||
assert "reconnectSnapshot" not in recipe["loras"][0]
|
||||
|
||||
|
||||
async def test_rematch_relaxed_matches_healthy_missing_entry_via_l4(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
# A healthy entry whose hash is NOT in the local library becomes an L4
|
||||
# filename match under relaxed mode when the base models agree.
|
||||
sha256 = ("B2" * 32).lower()
|
||||
item = _rematch_item(
|
||||
sha256=sha256,
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
|
||||
recipe: Dict[str, Any] = {
|
||||
"id": "r1",
|
||||
"base_model": "SD 1.5",
|
||||
"loras": [
|
||||
{
|
||||
"hash": "f" * 64, # not present locally
|
||||
"file_name": "detail.safetensors",
|
||||
}
|
||||
],
|
||||
}
|
||||
_set_recipe_cache(scanner, [recipe])
|
||||
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
|
||||
# Strict mode never touches the healthy entry.
|
||||
strict = await scanner.rematch_recipe_by_id("r1")
|
||||
assert strict["matched_entries"] == 0
|
||||
assert saved == []
|
||||
|
||||
result = await scanner.rematch_recipe_by_id("r1", relaxed=True)
|
||||
|
||||
assert result["matched_entries"] == 1
|
||||
assert result["details"]["matched"] == [
|
||||
{
|
||||
"type": "lora",
|
||||
"entry": "detail.safetensors",
|
||||
"file_name": "detail.safetensors",
|
||||
"match_level": "L4",
|
||||
"lora_index": 0,
|
||||
}
|
||||
]
|
||||
entry = recipe["loras"][0]
|
||||
assert entry["hash"] == sha256
|
||||
assert entry["reconnectSnapshot"]["hash"] == "f" * 64
|
||||
assert saved == [recipe]
|
||||
|
||||
|
||||
async def test_rematch_matched_details_carry_lora_index_and_bulk_flattens_l4(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
sha256_l1 = ("C3" * 32).lower()
|
||||
l1_item = _civitai_lora_item(sha256=sha256_l1, file_name="l1.safetensors")
|
||||
l4_item = _rematch_item(
|
||||
sha256=("D4" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([l1_item, l4_item], [], tmp_path)
|
||||
recipes: list[Dict[str, Any]] = [
|
||||
{
|
||||
"id": "r0",
|
||||
"base_model": "SD 1.5",
|
||||
"loras": [
|
||||
# index 0: not a candidate at all (healthy, strict run)
|
||||
{"hash": "zzz", "file_name": "other.safetensors"},
|
||||
# index 1: L4 filename match
|
||||
{"isDeleted": True, "file_name": "detail.safetensors"},
|
||||
# index 2: L1 hash match
|
||||
{
|
||||
"isDeleted": True,
|
||||
"hash": sha256_l1,
|
||||
"file_name": "old.safetensors",
|
||||
},
|
||||
],
|
||||
},
|
||||
{"id": "r1", "loras": []},
|
||||
]
|
||||
_set_recipe_cache(scanner, recipes)
|
||||
await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
await _spy_resort(scanner, monkeypatch)
|
||||
|
||||
result = await scanner.rematch_recipes_bulk(["r0", "r1"])
|
||||
|
||||
assert result["matched_entries"] == 2
|
||||
matched = result["details"][0]["matched"]
|
||||
assert matched[0]["lora_index"] == 1
|
||||
assert matched[0]["match_level"] == "L4"
|
||||
assert matched[1]["lora_index"] == 2
|
||||
assert matched[1]["match_level"] == "L1"
|
||||
# Only the L4 match is flattened for review; L1 matches need none.
|
||||
assert result["l4_matches"] == [
|
||||
{
|
||||
"recipe_id": "r0",
|
||||
"type": "lora",
|
||||
"entry": "detail.safetensors",
|
||||
"file_name": "detail.safetensors",
|
||||
"lora_index": 1,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_rematch_recipe_by_id_returns_flattened_l4_matches(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
# The single-recipe return carries the same flattened l4_matches shape
|
||||
# as the bulk/global paths so the frontend results modal works for all
|
||||
# three entry points.
|
||||
l4_item = _rematch_item(
|
||||
sha256=("F6" * 32).lower(),
|
||||
sub_type="lora",
|
||||
base_model="SD 1.5",
|
||||
file_name="detail.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([l4_item], [], tmp_path)
|
||||
recipe: Dict[str, Any] = {
|
||||
"id": "r1",
|
||||
"base_model": "SD 1.5",
|
||||
"loras": [{"isDeleted": True, "file_name": "detail.safetensors"}],
|
||||
}
|
||||
_set_recipe_cache(scanner, [recipe])
|
||||
await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
|
||||
result = await scanner.rematch_recipe_by_id("r1")
|
||||
|
||||
assert result["l4_matches"] == [
|
||||
{
|
||||
"recipe_id": "r1",
|
||||
"type": "lora",
|
||||
"entry": "detail.safetensors",
|
||||
"file_name": "detail.safetensors",
|
||||
"lora_index": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_rematch_all_recipes_reports_l4_matches_in_completed_payload(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
l4_item = _rematch_item(
|
||||
sha256=("E5" * 32).lower(),
|
||||
sub_type="checkpoint",
|
||||
base_model="SDXL",
|
||||
file_name="realistic.safetensors",
|
||||
)
|
||||
scanner, _, _ = _make_rematch_scanner([], [l4_item], tmp_path)
|
||||
recipe: Dict[str, Any] = {
|
||||
"id": "r1",
|
||||
"loras": [],
|
||||
"checkpoint": {
|
||||
"isDeleted": True,
|
||||
"file_name": "realistic.safetensors",
|
||||
"baseModel": "SDXL",
|
||||
},
|
||||
}
|
||||
_set_recipe_cache(scanner, [recipe])
|
||||
await _spy_rematch_persistence(scanner, monkeypatch)
|
||||
await _spy_resort(scanner, monkeypatch)
|
||||
|
||||
events: list[Dict[str, Any]] = []
|
||||
|
||||
async def cb(ev: Dict[str, Any]) -> None:
|
||||
events.append(ev)
|
||||
|
||||
result = await scanner.rematch_all_recipes(progress_callback=cb)
|
||||
|
||||
expected_l4 = [
|
||||
{
|
||||
"recipe_id": "r1",
|
||||
"type": "checkpoint",
|
||||
"entry": "realistic.safetensors",
|
||||
"file_name": "realistic.safetensors",
|
||||
}
|
||||
]
|
||||
# Checkpoint matches carry no lora_index (the checkpoint restore
|
||||
# endpoint only needs recipe_id).
|
||||
assert result["l4_matches"] == expected_l4
|
||||
completed = [e for e in events if e["status"] == "completed"]
|
||||
assert completed and completed[0]["l4_matches"] == expected_l4
|
||||
|
||||
|
||||
|
||||
async def test_find_all_duplicate_recipes_groups_by_fingerprint(recipe_scanner, monkeypatch):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
Reference in New Issue
Block a user