# Story: Sync fails fast + modal when a model is down **Phase:** `41_sync_fail_fast_models` · **Source:** `TODO.md` L4 · **E2E:** `tests/e2e/test_sync_model_down.py` ## Bug report (verbatim, `TODO.md` L4) > "If the embedding or lite model is not accessible the sync button > should fail fast and there should be a modal error popup explaining > that the model isn't available." ## Narrative As **the admin**, when I press "Sync sources" with the aipi models (`embed` or `lite`) unreachable, I don't want to wait through git clones and a partial import to discover the KB can't be updated — and a tooltip on a button is not a readable error. The sync should **fail fast** (before any expensive work) with a clear "the model isn't available" message, shown in a **modal dialog** I can read and dismiss. - **Given** the `embed` or `lite` model endpoint is unreachable - **When** I press "Sync sources" - **Then** the run fails within a couple of seconds (before any git clone), the button settles retry-ready, and a modal dialog explains which model isn't available. ## Acceptance criteria 1. **Server fail-fast:** `POST /api/sync` with a dead LLM endpoint reaches `state: "failed"` with a message naming the unavailable model (embedding first, then summary/lite) **without** cloning any source — the probe (one small embedding + one tiny completion against `BOR_LLM_SUMMARY_MODEL`) runs before source resolution and before any `clone_or_pull`. 2. **Modal:** on a failed sync the page shows a modal error dialog (`role="alertdialog"`, `aria-modal="true"`) with a title, the sanitized error text (rendered via `textContent` — XSS-safe), and a close control; it closes on the close button, `Esc`, or backdrop click; focus moves into the dialog on open and returns to `#sync-btn` on close. 3. **Every page:** the modal is built by the shared header module (`frontend/assets/header.js`), which owns the sync state machine — so it appears wherever `#sync-btn` exists (all six pages from phase 34). 4. **Existing surfaces kept:** the button's failed-state `title` / `aria-label` / `.is-error` affordance and the Sources page's `#sync-error-banner` (via `bor:sync-status`) are unchanged — the modal is the primary, readable surface. 5. **Success path unchanged:** a healthy model still runs clone → import → overview exactly as phase 32/35/38 define it (regression). ## Owner-confirmed (2026-08-27, roadmap A4) 1. **The probe runs before git clones** — the fastest possible failure; it costs one small embedding request and one ~1-token completion. 2. **The modal is the primary failure surface on every page;** the button-title affordance and the Sources banner stay as secondary surfaces. ## UI Visualization & Structure - **Server** (`app/rag/llm.py`, `app/api/sync.py`): a new `ModelUnavailableError` (subclass of `LLMError`) + an `async check_models(llm)` probe: `embed_one("sync model check")` then a tiny `chat([...])` against the summary model; each failure mode maps to a message naming the model and that it isn't available (the sync sanitizer's credential masking still applies downstream). `_run_sync` calls it first, after `LLMClient()` construction — before `effective_sources`, before any clone. - **UI** (`frontend/assets/header.js`, `frontend/assets/styles.css`): `applySyncFailure(status)` additionally opens `showSyncModal(status)`: a lazily-created backdrop + `role="alertdialog"` panel appended to `` (so no page markup changes), error text via `textContent`, close button + `Esc` + backdrop-click dismissal, focus management as in AC 2. Styled with the existing dark-theme error palette (PLAN §7.2: `#fca5a5` on `#2d1318` class, error border), `:focus-visible` per the global rule, no motion under `prefers-reduced-motion`. - **Non-goals:** no new endpoint, no retry-from-modal button (the button itself is retry-ready), no change to the 2 s poll lifecycle. ## Playwright Mapping Rule **Test Scenario → `tests/e2e/test_sync_model_down.py`** (mock LLM; DB up). The suite boots its **own module-scoped app** on a distinct port (conftest pattern used by `test_sync_button.py`) with `BOR_LLM_BASE_URL=http://127.0.0.1:9/v1` (dead port — connection refused) and a local `file://` fixture repo as the configured source, so a (regressed, non-fail-fast) run would spend time cloning before failing: 1. `test_model_down_fails_fast_with_modal` — admin login, click `#sync-btn`; within a short wall-clock budget (≤ ~10 s, vs the 60 s generous budget of the healthy-run suite) the button settles retry-ready **and** the modal is visible with an error naming the model; assert the dialog role/aria contract. 2. `test_modal_dismissal` — close via button, `Esc`, and backdrop click (one fresh failure per path); focus returns to `#sync-btn` each time. 3. `test_sync_error_surfaces_unaffected` (phase 32 regression) — after the failure the button keeps its `title` / `.is-error` affordance; on `/sources.html` the `#sync-error-banner` still renders off `bor:sync-status`. 4. `test_healthy_sync_still_succeeds` (phase 32/35 regression) — the session mock-backed app (or a second healthy module app) still runs the full clone → import → overview pipeline to "Synced HH:MM".