feat(recipes): add manual checkpoint reconnect for broken recipe entries

Checkpoint entries that cannot be restored by download (deleted,
unresolvable hash, or name-only remnants with no CivitAI identifiers)
now get the same remediation chain LoRAs already had:

- scanner: parameterized reconnect-suggestion ranking, update/restore/
  set-hash-invalid for the checkpoint entry, and clear hashInvalid on
  rematch write-back (was only done for LoRAs)
- persistence/handlers/routes: reconnect/restore/reconnect-suggestions/
  mark-hash-invalid endpoints under /api/lm/recipe/checkpoint/*
- modal: checkpoint reconnect UI (deleted/hash-invalid badges, inline
  form with suggestions, undo for reconnected entries); download
  failures mark the hash invalid only on explicit unresolvable signals
  (not found/deleted/404/410), matching the LoRA rule
- css: checkpoint undo button shares the LoRA undo styles
- i18n: the 14 new keys translated in all 9 locales
This commit is contained in:
Will Miao
2026-08-30 18:02:15 +08:00
parent bce7d1d30c
commit c8b9db5bf4
21 changed files with 2031 additions and 38 deletions
+118
View File
@@ -311,6 +311,28 @@ class StubPersistenceService:
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(payload={"success": True}, status=200)
async def reconnect_checkpoint(
self, *, recipe_scanner, recipe_id: str, target_name: str
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(payload={"success": True}, status=200)
async def restore_checkpoint(
self, *, recipe_scanner, recipe_id: str
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(payload={"success": True}, status=200)
async def get_checkpoint_reconnect_suggestions(
self, *, recipe_scanner, recipe_id: str, query: str | None = None
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(
payload={"success": True, "suggestions": []}, status=200
)
async def mark_checkpoint_hash_invalid(
self, *, recipe_scanner, recipe_id: str, hash_invalid: bool = True
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(payload={"success": True}, status=200)
async def bulk_delete(
self, *, recipe_scanner, recipe_ids: List[str]
) -> SimpleNamespace: # pragma: no cover
@@ -2050,3 +2072,99 @@ async def test_find_duplicates_forwards_include_prompt_and_assigns_unique_keys(
assert len(groups) == 2
assert {g["type"] for g in groups} == {"fingerprint", "source_path"}
assert len({g["key"] for g in groups}) == 2
# ---------------------------------------------------------------------------
# Checkpoint reconnect routes (manual remediation for recipe.checkpoint)
# ---------------------------------------------------------------------------
async def test_checkpoint_reconnect_route(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipe/checkpoint/reconnect",
json={"recipe_id": "r1", "target_name": "main"},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
async def test_checkpoint_reconnect_route_requires_target_name(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipe/checkpoint/reconnect",
json={"recipe_id": "r1"},
)
assert response.status == 400
async def test_checkpoint_restore_route(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipe/checkpoint/restore",
json={"recipe_id": "r1"},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
async def test_checkpoint_restore_route_requires_recipe_id(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipe/checkpoint/restore",
json={},
)
assert response.status == 400
async def test_checkpoint_reconnect_suggestions_route(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.get(
"/api/lm/recipe/r1/checkpoint/reconnect-suggestions?query=main"
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["suggestions"] == []
async def test_checkpoint_reconnect_suggestions_route_without_query(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.get(
"/api/lm/recipe/r1/checkpoint/reconnect-suggestions"
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
async def test_checkpoint_mark_hash_invalid_route(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipe/checkpoint/mark-hash-invalid",
json={"recipe_id": "r1"},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
async def test_checkpoint_mark_hash_invalid_route_requires_recipe_id(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipe/checkpoint/mark-hash-invalid",
json={},
)
assert response.status == 400