feat(sync): fail fast with a modal when a model is unavailable

TODO.md L4: with a dead model endpoint the sync discovered it only
mid-import, after slow clones — and a tooltip on the button is not a
readable error.

- app/rag/llm.py: ModelUnavailableError + check_models(llm) — a tiny
  pre-sync probe (one short embedding + one 1-token-scale completion)
  that fails naming the unavailable model (embed first, then the
  summary model); the sync sanitizer still masks credentials.
- app/api/sync.py: the probe is step 1 of _run_sync — before source
  resolution and before any clone_or_pull; a model failure is just
  another 'failed' state (no new endpoint, A10/A12 untouched).
- frontend/assets/header.js: applySyncFailure now also opens the
  module-owned error modal (every page carrying #sync-btn, zero
  page-markup changes): lazily built backdrop + role=alertdialog
  panel, error text via textContent, close via button / Esc /
  backdrop, focus in-and-out to #sync-btn (with a body→#sync-btn
  fallback — the run's disabled button drops focus to <body>).
- frontend/assets/styles.css: the modal on the phase-08 error palette
  (z-index above the header, .is-open open/close, reduced-motion
  stilling, 44px close target).
- Tests: probe unit tests (both up / embed down / summary down /
  custom model names), sync integration (fail-fast before any clone,
  probe-before-effective_sources ordering, credential masking,
  healthy regression), the phase-41 source pins, and the story E2E
  (two module apps on distinct ports — dead endpoint on a closed
  loopback port vs session mock: ≤10 s fail-fast + modal contract,
  all three dismissal paths with focus out to #sync-btn, button
  title/.is-error + Sources banner untouched, healthy phase-32
  lifecycle regression to 'Synced HH:MM').

E2E (isolation): test_sync_model_down.py 4/4, test_sync_button.py
3/3, test_git_sources_admin.py 6/6, test_local_directory_sources.py
3/3; unit+integration 721 passed, app/ coverage 99%; ruff + pyright
clean.
This commit is contained in:
2026-08-27 23:44:35 -04:00
parent 6f9e033117
commit 6cf1df9bf2
9 changed files with 1203 additions and 12 deletions
+39
View File
@@ -44,6 +44,15 @@ class LLMError(RuntimeError):
"""The chat-completions endpoint failed (network, HTTP, or mid-stream)."""
class ModelUnavailableError(LLMError):
"""One of the models a sync needs is unreachable (phase 41 probe).
Raised by :func:`check_models` when the pre-sync probe finds the
embedding or summary model down; the message names the model so the
admin can fix the right thing.
"""
@dataclass(frozen=True)
class StreamPiece:
"""One piece of a streamed chat turn (phase 17, PLAN §4 extension).
@@ -408,3 +417,33 @@ class LLMClient:
raise
except Exception as e: # noqa: BLE001 — wrap transport-level failures
raise LLMError(f"chat stream from {self.settings.llm_base_url} failed: {e}") from e
async def check_models(llm: LLMClient) -> None:
"""Verify the models a sync needs (embed + summary) before any
expensive work; raise ModelUnavailableError naming the model.
The probe is deliberately tiny — one short embedding
(``sync model check``) and one 1-token-scale completion (``ping``)
— so a dead endpoint is discovered cheaper than a single git
clone. The sync sanitizer downstream (``app.api.sync
._sanitize_error``) still masks any credentials embedded in the
wrapped error text, so the raw endpoint URL in the original
exception is safe to include.
"""
embed_model = llm.settings.llm_embed_model
try:
await llm.embed_one("sync model check")
except Exception as e: # noqa: BLE001 — wrap EmbeddingError + transport failures
raise ModelUnavailableError(
f"The embedding model ('{embed_model}') is not available — "
f"check the model endpoint and retry. ({e})"
) from e
summary_model = llm.settings.llm_summary_model
try:
await llm.chat([{"role": "user", "content": "ping"}])
except Exception as e: # noqa: BLE001 — wrap LLMError + transport failures
raise ModelUnavailableError(
f"The summary model ('{summary_model}') is not available — "
f"check the model endpoint and retry. ({e})"
) from e