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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user