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
+92 -4
View File
@@ -44,8 +44,14 @@
* timeout — the server state is authoritative). Every state change
* dispatches window "bor:sync-status" (detail = the status object)
* so the Sources page renders its #sync-result line +
* #sync-error-banner off the event; on non-Sources pages the
* failed state is visible in the button's title + aria-label;
* #sync-error-banner off the event; the button title/aria are the
* secondary failure surfaces — and a failed run ALSO opens the
* module-owned error modal (phase 41, 2026-08-27, TODO.md L4, the
* primary readable failure surface): lazily built by this module,
* appended to <body>, error text textContent-rendered, closable via
* its button / Esc / backdrop, focus in-and-out to #sync-btn —
* every page carrying #sync-btn gets it with zero page-markup
* changes;
* • the SINGLE New chat binding (phase 34 task 02 — it was
* duplicated across app.js / sources.js / tuning.js / document.js):
* on the chat page (#messages exists) the module dispatches
@@ -336,8 +342,10 @@ if (newChatBtn) {
* at a time, one poll loop at a time);
* success → "Synced HH:MM"; failed → retry-ready "Sync sources"
* + the sanitized error in the button's title +
* aria-label (on non-Sources pages that IS where the
* failure is visible; the Sources banner is the event).
* aria-label + the module-owned error modal (phase 41,
* 2026-08-27, TODO.md L4 — the primary readable failure
* surface on every page; the button affordance and the
* Sources banner stay the secondary surfaces).
*
* Boot (admin only — non-admins never poll, the status endpoint is
* admin-only): one GET /api/sync/status on the SAME cached whoami —
@@ -460,6 +468,86 @@ function applySyncFailure(status) {
syncBtn.classList.add("is-error");
}
emitSyncStatus(status);
// Phase 41 (2026-08-27, TODO.md L4): the module-owned error modal —
// the primary readable failure surface (the button title/aria and the
// Sources banner above stay as the secondary surfaces).
showSyncModal(error);
}
/* ---------- sync failure modal (phase 41, 2026-08-27, TODO.md L4) ----------
*
* A tooltip on the button is not a readable error — a failed sync ALSO
* opens a modal dialog. It is built by THIS module (the owner of the
* sync state machine), so every page carrying #sync-btn gets it with
* zero page-markup changes: created lazily ONCE (module-level
* `syncModal`) and appended to <body> — a .sync-modal-backdrop (fixed,
* full-viewport dim) holding the .sync-modal panel
* (role="alertdialog", aria-modal, labelled + described). The error
* text is ALWAYS set via textContent (XSS-safe — never innerHTML with
* user data); a second failure while open updates the text IN PLACE
* (no stacking). Closes via the close button, Esc (ONE document
* keydown binding, acting only while open), or a click on the backdrop
* itself (never the panel); focus moves to the close button on open
* and back to the remembered element (#sync-btn — the control that
* started the run) on close. Null-safe: no #sync-btn (or no <body>) →
* no modal, exactly like the rest of this module.
*/
let syncModal = null; // the backdrop element — created once, lazily
let syncModalReturnFocus = null; // the element to refocus on close
function createSyncModal() {
const backdrop = document.createElement("div");
backdrop.className = "sync-modal-backdrop";
// Static skeleton — no user data anywhere in it; the error text is
// filled via textContent in showSyncModal, never interpolated here.
backdrop.innerHTML =
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
'<h2 id="sync-modal-title">Sync failed</h2>' +
'<p id="sync-modal-error"></p>' +
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
"</div>";
document.body.appendChild(backdrop);
// Close path 1: the close button (×).
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
// Close path 2: a click on the backdrop element itself — never one
// that bubbles up from the panel (event.target check).
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeSyncModal();
});
// Close path 3: Esc — ONE document-level keydown binding for the
// life of the page, acting only while the modal is open.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
});
return backdrop;
}
function showSyncModal(error) {
if (!syncBtn || !document.body) return; // null-safe: pages without the button
if (!syncModal) syncModal = createSyncModal();
// The sanitized error as TEXT (XSS-safe) — a second failure while
// open updates the text in place (no stacking, no focus jump).
syncModal.querySelector("#sync-modal-error").textContent = error;
if (syncModal.classList.contains("is-open")) return;
// First open: remember the focused element and move focus into the
// dialog (the close button). While the run was in flight the button
// was disabled (focus had fallen to <body>), so a body-level active
// element means "no meaningful focus target" — remember #sync-btn,
// the control that started the run, so the close returns focus there.
const active = document.activeElement;
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
syncModal.classList.add("is-open");
syncModal.querySelector(".sync-modal-close").focus();
}
function closeSyncModal() {
if (!syncModal || !syncModal.classList.contains("is-open")) return;
syncModal.classList.remove("is-open");
// Focus returns to the remembered element — #sync-btn when present.
const target = syncModalReturnFocus;
syncModalReturnFocus = null;
if (target && document.contains(target)) target.focus();
}
/* A run can only vanish with a server restart mid-sync (status resets
+78
View File
@@ -381,6 +381,84 @@ html::after {
white-space: nowrap;
}
/* Phase 41 (2026-08-27, TODO.md L4): the module-owned sync failure
modal — lazily built by header.js and appended to <body>, so every
page carrying #sync-btn gets it with zero page-markup changes.
Phase-08 error palette (PLAN §7.2): the panel sits on the error
surface --err-bg with a 1px --err-line border (the amber
--accent-line stays deflection-only), its title in --ink (≈14.5:1
on --err-bg) and the error text in --err-ink (9.1:1 on --err-bg).
The backdrop dims the page with --bg at 82% — no blur (the phase-08
no-blur anchor). Stacking: z-index 1000, the same overlay contract
as the doc-modal — above the sticky header (20) and the skip-link
(100). Open/close via .is-open (visibility/opacity — the closed
modal is unfocusable and inert); the close button keeps the global
3px :focus-visible outline and the 44px touch floor. */
.sync-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(10, 14, 23, 0.82);
visibility: hidden;
opacity: 0;
transition: opacity 120ms ease;
}
.sync-modal-backdrop.is-open {
visibility: visible;
opacity: 1;
}
.sync-modal {
position: relative;
width: 100%;
max-width: 28rem;
background: var(--err-bg);
border: 1px solid var(--err-line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
/* right padding clears the absolutely-positioned close button */
padding: 1.1rem 3rem 1.25rem 1.25rem;
}
#sync-modal-title {
margin: 0 0 0.6rem;
font-size: 1.1rem;
color: var(--ink);
}
#sync-modal-error {
margin: 0 0 1rem;
font-family: var(--mono);
font-size: 0.9rem;
color: var(--err-ink);
overflow-wrap: anywhere;
}
.sync-modal-close {
position: absolute;
top: 0.3rem;
right: 0.3rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0;
border-radius: 999px;
border: 1px solid var(--err-line);
background: transparent;
color: var(--err-ink);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
}
.sync-modal-close:hover { background: rgb(239 68 68 / 0.15); }
/* No motion under reduced motion (same opt-out pattern as the
phase-25 background layers and the doc-modal backdrop). */
@media (prefers-reduced-motion: reduce) {
.sync-modal-backdrop { transition: none; }
}
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
(not removed) below 640px so the accessible name keeps the word.