Converts the 9 TODO items into an executable phase roadmap (Protocol B, appended after phase 39): - 40 tuning toggle anonymous flash (TODO L3) - 41 sync fail-fast + modal when a model is down (TODO L4) - 42 no reply autoscroll (TODO L5) - 43 thinking scroll back — user scroll + gated autoscroll (TODO L7) - 44 markdown tables (TODO L6) - 45 agent unlimited tool calls behind BOR_AGENT_MAX_ROUNDS (TODO L8) - 46 mobile hamburger nav (TODO L9) - 47 quadlet + jinja import formats, A9 revision (TODO L10–L11) Each phase carries a user story, a dedicated Playwright E2E suite plan, and owner-locked decisions (R1 A9 format extension, R2 phase-37 budget revision, A1–A5 scope decisions) confirmed 2026-08-27. Also records the completed phases 30–39 todo/ -> complete/ moves that were pending in the working tree. TODO.md is cleared (items now live in .agent/phases/todo/).
5.2 KiB
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
embedorlitemodel 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
- Server fail-fast:
POST /api/syncwith a dead LLM endpoint reachesstate: "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 againstBOR_LLM_SUMMARY_MODEL) runs before source resolution and before anyclone_or_pull. - 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 viatextContent— 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-btnon close. - 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-btnexists (all six pages from phase 34). - Existing surfaces kept: the button's failed-state
title/aria-label/.is-erroraffordance and the Sources page's#sync-error-banner(viabor:sync-status) are unchanged — the modal is the primary, readable surface. - 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)
- The probe runs before git clones — the fastest possible failure; it costs one small embedding request and one ~1-token completion.
- 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 newModelUnavailableError(subclass ofLLMError) + anasync check_models(llm)probe:embed_one("sync model check")then a tinychat([...])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_synccalls it first, afterLLMClient()construction — beforeeffective_sources, before any clone. - UI (
frontend/assets/header.js,frontend/assets/styles.css):applySyncFailure(status)additionally opensshowSyncModal(status): a lazily-created backdrop +role="alertdialog"panel appended to<body>(so no page markup changes), error text viatextContent, close button +Esc+ backdrop-click dismissal, focus management as in AC 2. Styled with the existing dark-theme error palette (PLAN §7.2:#fca5a5on#2d1318class, error border),:focus-visibleper the global rule, no motion underprefers-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:
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.test_modal_dismissal— close via button,Esc, and backdrop click (one fresh failure per path); focus returns to#sync-btneach time.test_sync_error_surfaces_unaffected(phase 32 regression) — after the failure the button keeps itstitle/.is-erroraffordance; on/sources.htmlthe#sync-error-bannerstill renders offbor:sync-status.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".