From 52136fe3070b4cb378659de79d2abd19e1c54cde Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 25 Aug 2026 21:38:36 -0400 Subject: [PATCH] =?UTF-8?q?feat(admin):=20one-click=20sources=20sync=20?= =?UTF-8?q?=E2=80=94=20admin-only=20button=20triggers=20git=20clone/pull?= =?UTF-8?q?=20+=20re-import=20+=20KB=20overview=20refresh=20with=20polled?= =?UTF-8?q?=20live=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../32_admin_sync_button/01_sync_api.md | 0 .../32_admin_sync_button/02_ui_button.md | 0 .agent/user_stories/admin-sync-button.md | 92 +++++ README.md | 38 ++ app/api/sync.py | 177 ++++++++ app/main.py | 2 + frontend/assets/header.js | 9 +- frontend/assets/sources.js | 231 +++++++++++ frontend/assets/styles.css | 59 +++ frontend/sources.html | 28 +- tests/e2e/test_sync_button.py | 283 +++++++++++++ tests/integration/test_sync_api.py | 379 ++++++++++++++++++ tests/unit/test_sync_button.py | 325 +++++++++++++++ 13 files changed, 1621 insertions(+), 2 deletions(-) rename .agent/phases/{todo => complete}/32_admin_sync_button/01_sync_api.md (100%) rename .agent/phases/{todo => complete}/32_admin_sync_button/02_ui_button.md (100%) create mode 100644 .agent/user_stories/admin-sync-button.md create mode 100644 app/api/sync.py create mode 100644 tests/e2e/test_sync_button.py create mode 100644 tests/integration/test_sync_api.py create mode 100644 tests/unit/test_sync_button.py diff --git a/.agent/phases/todo/32_admin_sync_button/01_sync_api.md b/.agent/phases/complete/32_admin_sync_button/01_sync_api.md similarity index 100% rename from .agent/phases/todo/32_admin_sync_button/01_sync_api.md rename to .agent/phases/complete/32_admin_sync_button/01_sync_api.md diff --git a/.agent/phases/todo/32_admin_sync_button/02_ui_button.md b/.agent/phases/complete/32_admin_sync_button/02_ui_button.md similarity index 100% rename from .agent/phases/todo/32_admin_sync_button/02_ui_button.md rename to .agent/phases/complete/32_admin_sync_button/02_ui_button.md diff --git a/.agent/user_stories/admin-sync-button.md b/.agent/user_stories/admin-sync-button.md new file mode 100644 index 0000000..2709d36 --- /dev/null +++ b/.agent/user_stories/admin-sync-button.md @@ -0,0 +1,92 @@ +# Story: Admin Sync Button (One-Click Doc Import Sync) + +**Phase:** `32_admin_sync_button.md` · **E2E:** `tests/e2e/test_sync_button.py` + +## Narrative + +As **the admin (owner)**, I don't want to SSH in and hand-run the import +script every time my notes repos move. I want **a button that only I can +see** — on the Sources page — that **triggers a doc import sync by +cloning the relevant repos and then running the import script**, with +live feedback so I always know whether it is running, what it changed, +or why it failed. + +- **Given** `BOR_GIT_SOURCES` is set (the git repos) and I am signed in + as the admin +- **When** I click **Sync sources** on the Sources page +- **Then** the app clones/pulls each repo, re-imports with prune (the + `--prune` equivalent — the canonical "mirror the repos" action), and + refreshes the KB overview when anything changed — while the button + reports the whole lifecycle (**Syncing…** → **Synced HH:MM** + counts, + or an error banner naming the failure) and never sits stale or stuck. + Anonymous visitors never see the button, and the sync endpoints answer + them with 403. + +## Acceptance criteria +1. **Admin-only visibility.** The button ships `hidden` in + `sources.html` (anonymous-safe) and `header.js` reveals it for the + admin on the SAME cached whoami that reveals `#nav-sources` / + `#nav-tuning` (one fetch, no extra whoami call); anonymous users + never see it. +2. **The sync API (A10 extended, A12 in-process).** `POST /api/sync` + (admin only) starts one background task — `clone_or_pull` each + `BOR_GIT_SOURCES` repo (phase 28, reused) → `import_sources(prune=True)` + → `regenerate_overview` when the KB changed (phase 31) — and returns + `202`. A second trigger while a run is in flight returns + `409 {"detail": "a sync is already running"}` (one sync at a time). + `GET /api/sync/status` (admin only) reports `idle | running | + success | failed` with ISO-8601 timestamps and the run's `detail` / + `error`. Both endpoints answer anonymous callers with 403. +3. **Loud failure.** An unset/empty `BOR_GIT_SOURCES` fails the sync with + "no git sources configured (BOR_GIT_SOURCES)" (manual `--source` dirs + have no repo to clone); a `GitSyncError` fails the run with git's + stderr (the repo named, credentials masked) before any import. +4. **The §7.4 "never stale" lifecycle.** Click → `202` → button disabled + with **Syncing…** (spinning icon, `aria-busy`) + a 2 s poll of the + status endpoint — the ONLY feedback timer; there is no client-side + hard timeout (a sync can legitimately run for minutes; the server + state is authoritative). Success → enabled, **Synced HH:MM** (local + time of `finished_at`) + the last result in `#sync-result` + (`role="status"` / `aria-live="polite"`; "added" always announced, + zero terms omitted, a no-op run reads `0 added · 1 unchanged`). + Failure → enabled, retry-ready **Sync sources** + the `role="alert"` + banner naming the error. A `409` adopts the in-flight run (never a + second poll loop); a reload mid-sync re-attaches to the running run; + a `403` hides the button (defense in depth). +5. **Idempotent.** Re-syncing an unchanged repo is a fast-forward pull + + sha256 hash skip — nothing re-embedded, the overview left alone + (change-gated), the result `0 added · 1 unchanged`. +6. **Quality gates.** Integration (`tests/integration/test_sync_api.py`): + anonymous 403s; idle → running → success/failed transitions with git + + import + overview mocked; 409 double trigger; `GitSyncError` → + `failed` with the repo named and the import never called; `prune=True` + asserted. Unit (`tests/unit/test_sync_button.py`, frontend-assertion): + the ship-hidden markup, the header reveal, the state machine (2 s + poll, 202/409/403 branches, terminal labels, single-poll guard, no + client timeout), the CSS states (spin + reduced-motion opt-out, + disabled, focus-visible, contrast ≥ 4.5:1, 44 px touch floor). + Coverage `app/` > 90 % (`app/api/sync.py` fully covered). +7. **E2E (this story's gate).** `tests/e2e/test_sync_button.py`, run in + isolation: a real local `file://` git fixture repo (deterministic, no + network — git is a documented environment prerequisite, phase 28) with + the mock LLM proves the admin-only visibility, the full lifecycle + against the REAL clone → import → overview path (including the + idempotent second run and the fresh `kb_overview` row), and the 409 + double trigger. + +## Playwright Mapping Rule +`tests/e2e/test_sync_button.py` — run in isolation (Chromium + +`podman compose up -d db` + git on PATH; mock LLM, no live aipi). The +module overrides the session app fixture with per-module env +(`BOR_GIT_SOURCES=file://`, its own `BOR_SOURCES_DIR`) and +truncates the KB tables before each test (the E2E isolation pattern): + +1. `test_anonymous_sees_no_button` → AC 1 + 2 (the button never leaves + `hidden`; both endpoints 403). +2. `test_admin_sync_lifecycle` → AC 2 + 4 + 5 (button visible for the + admin; click → **Syncing…** (disabled) → **Synced HH:MM** + + `1 added`; the fixture path `notes/sync-fixture.md` in the Sources + table; the `kb_overview` row fresh and non-empty (DB check); the + idempotent second run → `0 added · 1 unchanged`). +3. `test_double_trigger_409` → AC 2 (second trigger while running → 409 + with the exact detail; the single in-flight run still completes). diff --git a/README.md b/README.md index 66bb07d..d7198de 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,44 @@ BOR_SOURCES_DIR=~/bor-sources # default; each repo lands in // **nothing** (no partial junk). Fix the URL/connectivity and re-run — the other checkouts stay on disk and are pulled as usual. +### Sync from the UI + +The **Sync sources** button on the **Sources** page — visible to the +**admin only** (anonymous visitors never see it) — runs the whole +git-source refresh in one click, in-process: + +1. **clone/pull** every `BOR_GIT_SOURCES` repo (the same + `clone_or_pull` the CLI uses — shallow clone on first run, + `git pull --ff-only` afterwards); +2. **re-import with prune** — the `--prune` equivalent, so files deleted + upstream leave the index (the button is the canonical "mirror the + repos" action); the sha256 delta still skips unchanged files, so an + unchanged re-sync re-embeds nothing; +3. **regenerate the KB overview** (the `` outline every + chat turn injects) — but only when the import actually changed the + knowledge base. + +- **Prerequisites:** `BOR_GIT_SOURCES` must be set — an unset/empty list + fails the sync loudly ("no git sources configured"), because the button + targets the git repos only (manual `--source` directories have no repo + to clone) — and `git` must be on the app's `PATH`. +- **States:** clicking starts the run (`202`) and the button goes + disabled with **Syncing…** (spinning icon) while the page polls + `GET /api/sync/status` every 2 s. There is deliberately **no + client-side timeout** — a clone + embed can legitimately take minutes, + so the poll is the feedback loop and the server state is authoritative. + On success the button settles to **Synced HH:MM** with the last result + in a live region (`1 added`, `0 added · 1 unchanged`, …); on failure it + re-enables (retry-ready) and a red error banner names the failure (git's + stderr, with any embedded credentials masked). +- **One sync at a time:** a second trigger while a run is in flight gets + `409` ("a sync is already running"); the UI adopts the in-flight run + instead of starting a second one, and a page reload mid-sync re-attaches + to it the same way. +- **Idempotent:** re-syncing unchanged repos is a no-op — fast-forward + pull, hash skip, and the overview is left alone (its regeneration is + change-gated). + ## Checking retrieval quality Ask the *real* pipeline (live aipi embeddings + the current KB) whether a diff --git a/app/api/sync.py b/app/api/sync.py new file mode 100644 index 0000000..ad9706e --- /dev/null +++ b/app/api/sync.py @@ -0,0 +1,177 @@ +"""Sources sync API — one-click KB mirror (phase 32, task 01). + +Admin-only ``POST /api/sync`` + ``GET /api/sync/status`` behind the +existing :func:`app.core.auth.require_admin` (A10 extended, phase 16 +pattern — the public API surface stays stateless, the signed cookie +remains the only session state, same as ``/api/steering``). + +The button's backend runs the full document sync **in-process** (A12 +untouched — no queue, no new services): one ``asyncio`` background task +plus a module-level :class:`SyncStatus` that the UI polls every 2 s +(task 02). One sync at a time — ``POST`` while a run is in flight is +409; the status object is authoritative, so the UI can never sit on a +stale button state (§7.4 adaptation, phase locked decisions). + +Pipeline (the canonical "mirror the repos" action — phase locked +decisions): + +1. resolve the ``BOR_GIT_SOURCES`` URLs — empty/missing fails loudly + (``no git sources configured``) instead of silently importing the + legacy local directories; +2. :func:`scripts.git_sync.clone_or_pull` each repo into + ``BOR_SOURCES_DIR//`` (phase 28 — reused, not + re-implemented; a failing repo aborts before any import); +3. ``import_sources(..., prune=True)`` — prune so files deleted + upstream leave the index (the CLI's no-prune default is unchanged); +4. when the import changed the KB (added + updated > 0), + ``regenerate_overview`` refreshes the single ``kb_overview`` row + (phase 31 trigger, best-effort inside). + +Status is in memory: a restart mid-sync loses the running state +(accepted — the next click re-syncs idempotently). +""" +from __future__ import annotations + +import asyncio +import logging +import re +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +from fastapi import APIRouter, Depends, HTTPException + +from app.config import get_settings +from app.core.auth import require_admin +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient +from app.rag.overview import regenerate_overview +from scripts.git_sync import GitSyncError, clone_or_pull +from scripts.import_docs import repo_name + +logger = logging.getLogger("app.api.sync") + +router = APIRouter( + prefix="/sync", + tags=["sync"], + dependencies=[Depends(require_admin)], # phase 16 pattern: admin-only surface +) + +#: ``user:pass@`` inside any error text (git stderr, endpoint URLs) — +#: masked so a sync failure can never leak credentials into the UI. +_CREDS_RE = re.compile(r"[A-Za-z0-9._~%*-]+:[A-Za-z0-9._~%*-]+@") + + +def _sanitize_error(message: str) -> str: + """Mask credentials embedded in an error string (no secrets in the UI). + + Git's stderr is otherwise surfaced verbatim (phase locked decisions) — + it names the failing repo and git's reason, which is what the admin + needs to fix things. + """ + return _CREDS_RE.sub("*****@", message) + + +@dataclass +class SyncStatus: + """In-memory state of the (at most one) in-flight sync run. + + ``state`` is a four-state machine: ``idle`` (never run / reset), + ``running``, ``success``, ``failed``. Terminal states carry the run's + ``detail`` (success) or ``error`` (failure) so the UI can render the + last result after a page reload (task 02's re-attach behavior). + """ + + state: Literal["idle", "running", "success", "failed"] = "idle" + started_at: datetime | None = None + finished_at: datetime | None = None + detail: dict[str, Any] = field(default_factory=dict) + error: str | None = None + + +_status = SyncStatus() +_task: asyncio.Task[None] | None = None + + +@router.get("/status") +def sync_status() -> dict[str, Any]: + """Current sync state (the UI polls this every 2 s — task 02). + + ``started_at`` / ``finished_at`` are ISO-8601 strings or null. + """ + return { + "state": _status.state, + "started_at": _status.started_at.isoformat() if _status.started_at else None, + "finished_at": _status.finished_at.isoformat() if _status.finished_at else None, + "detail": _status.detail, + "error": _status.error, + } + + +@router.post("", status_code=202) +async def start_sync() -> dict[str, str]: + """Start the clone → import → overview sync as a background task. + + 202 + ``sync started`` kicks off :func:`_run_sync` on the app's event + loop. 409 when a run is already in flight (one sync at a time — the + status endpoint is the single source of truth for the run, and the + UI re-attaches to it rather than starting a second one). + """ + global _task + if _task is not None and not _task.done(): + raise HTTPException(status_code=409, detail="a sync is already running") + _task = asyncio.create_task(_run_sync()) + return {"detail": "sync started"} + + +async def _run_sync() -> None: + """The full sync pipeline, one in-process background task. + + Every failure mode (git, embeddings, anything else) lands in the + ``failed`` state with a sanitized ``error`` string — a background + task must die in state, never as an unobserved exception. + ``CancelledError`` is deliberately *not* caught: app shutdown + cancels the task, and swallowing that would mask a real stop. + """ + _status.state = "running" + _status.started_at = datetime.now(UTC) + _status.finished_at = None + _status.detail = {} + _status.error = None + try: + settings = get_settings() + git_urls = settings.git_source_list + if not git_urls: + # The button targets BOR_GIT_SOURCES only (manual --source + # dirs have no repo to clone) — an empty config fails loudly + # instead of silently importing the legacy directories. + raise GitSyncError("no git sources configured (BOR_GIT_SOURCES)") + logger.info("sync: started repos=%d", len(git_urls)) + sources_root = Path(settings.sources_dir).expanduser() + sources = [clone_or_pull(url, sources_root / repo_name(url)) for url in git_urls] + llm = LLMClient() + summary: ImportSummary = await import_sources(sources, llm, prune=True) + overview = False + if summary.added + summary.updated > 0: + overview = await regenerate_overview(llm) + _status.state = "success" + _status.finished_at = datetime.now(UTC) + _status.detail = { + "files": summary.files, + "added": summary.added, + "updated": summary.updated, + "unchanged": summary.unchanged, + "pruned": summary.pruned, + "errors": summary.errors, + "chunks": summary.chunks, + "summaries": summary.summaries, + "summary_errors": summary.summary_errors, + "overview": overview, + } + logger.info("sync: done detail=%s", _status.detail) + except Exception as e: # noqa: BLE001 — a background task dies in state, see above + logger.exception("sync: failed") + _status.state = "failed" + _status.finished_at = datetime.now(UTC) + _status.error = _sanitize_error(str(e)) diff --git a/app/main.py b/app/main.py index cd1af95..0ce7dd8 100644 --- a/app/main.py +++ b/app/main.py @@ -25,6 +25,7 @@ from app.api.docs import router as docs_router from app.api.health import router as health_router from app.api.steering import router as steering_router from app.api.suggestions import router as suggestions_router +from app.api.sync import router as sync_router from app.config import get_settings from app.core.auth import ensure_admin_configured from app.core.debugging import configure_debugging @@ -64,6 +65,7 @@ def create_app() -> FastAPI: app.include_router(docs_router, prefix="/api") app.include_router(chat_router, prefix="/api") app.include_router(steering_router, prefix="/api") + app.include_router(sync_router, prefix="/api") static_dir = Path(settings.static_dir).resolve() if static_dir.is_dir(): diff --git a/frontend/assets/header.js b/frontend/assets/header.js index d9b10c6..921ce4e 100644 --- a/frontend/assets/header.js +++ b/frontend/assets/header.js @@ -12,7 +12,10 @@ * every page that has a nav (chat, sources, tuning, login), * revealed for admin. The links SHIP hidden in the HTML * (anonymous-safe default — the phase-16 "absent, not hidden" - * spirit), so no anonymous user ever sees one for a frame; + * spirit), so no anonymous user ever sees one for a frame; and the + * Sources page's "Sync sources" button (#sync-btn, phase 32) — + * the same ship-hidden / reveal-for-admin contract on the SAME + * cached whoami (one fetch, no extra request); * • the sign-out click binding (POST /api/logout → reload) — moved * here from app.js so there is exactly one implementation; * • clearChatStorage() — the phase-14 conversation key, for the @@ -68,6 +71,10 @@ export async function initSharedHeader() { // same ship-hidden / reveal-for-admin contract as the Sources link. const navTuning = document.querySelector("#nav-tuning"); if (navTuning) navTuning.hidden = !admin; + // Phase 32: the Sources page's "Sync sources" button — admin-only, + // revealed on this same cached whoami (anonymous users never see it). + const syncBtn = document.querySelector("#sync-btn"); + if (syncBtn) syncBtn.hidden = !admin; return admin; } diff --git a/frontend/assets/sources.js b/frontend/assets/sources.js index 3a8defa..ec3d9ea 100644 --- a/frontend/assets/sources.js +++ b/frontend/assets/sources.js @@ -146,6 +146,236 @@ function showEmpty() { if (tableWrap) tableWrap.hidden = true; } +/* ---------- Phase 32: the admin "Sync sources" button (§7.4) ---------- + * The "never stale" lifecycle for a long background job: + * + * idle → click → POST /api/sync + * 202 → "Syncing…" (disabled, aria-busy, spinning icon) + a + * 2 s poll of GET /api/sync/status — the feedback loop; + * 409 adopts the in-flight run the same way (one poll + * loop at a time, never two); + * success → "Synced HH:MM" + last-result counts in #sync-result + * (aria-live — announced to screen readers) + the + * catalog re-fetches live (never a stale table); + * failed → "Sync sources" (retry-ready) + the role="alert" + * banner naming the error. + * + * NO client-side hard timeout (phase locked decision): a sync can + * legitimately run for minutes (clone + embed), so the 2 s poll is the + * feedback loop and the server state is authoritative — the button is + * disabled until the run reaches a terminal state, so it can never sit + * stale OR stuck. On load (admin only) the page re-attaches: a running + * run re-enters the running state (reload mid-sync), a terminal run + * renders its last result. A 403 anywhere hides the button (defense in + * depth — header.js's whoami reveal is the primary gate). + */ +const syncBtn = document.querySelector("#sync-btn"); +const syncLabel = document.querySelector("#sync-label"); +const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null; +const syncResult = document.querySelector("#sync-result"); +const syncErrorBanner = document.querySelector("#sync-error-banner"); +const syncErrorText = document.querySelector("#sync-error-text"); + +const SYNC_POLL_MS = 2000; // the 2 s status poll (task 02) +let syncPollTimer = null; // at most ONE live poll loop + +function stopSyncPolling() { + if (syncPollTimer !== null) { + clearTimeout(syncPollTimer); + syncPollTimer = null; + } +} + +function showSyncError(detail) { + if (syncErrorText) syncErrorText.textContent = detail || "The sync failed."; + if (syncErrorBanner) syncErrorBanner.hidden = false; +} + +function hideSyncError() { + if (syncErrorText) syncErrorText.textContent = ""; + if (syncErrorBanner) syncErrorBanner.hidden = true; +} + +/* The local HH:MM of finished_at — 24-hour, locale-independent, so the + * "Synced 14:32" last-result label is deterministic. */ +function fmtSyncTime(iso) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/* The last-result line for #sync-result (aria-live). "added" is ALWAYS + * announced (the run's headline term); "updated" / "pruned" only when + * they happened (zero terms omitted); "unchanged" whenever it is + * non-zero — or whenever nothing was added or updated, so a no-op + * re-sync reads "0 added · 1 unchanged" instead of an empty live + * region (the story gate's idempotency check). */ +function fmtSyncResult(detail) { + const d = detail || {}; + const added = d.added || 0; + const updated = d.updated || 0; + const parts = [`${added} added`]; + if (updated > 0) parts.push(`${updated} updated`); + if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) { + parts.push(`${d.unchanged || 0} unchanged`); + } + if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`); + return parts.join(" · "); +} + +function enterRunningState() { + syncBtn.disabled = true; + syncBtn.setAttribute("aria-busy", "true"); + if (syncIcon) syncIcon.classList.add("is-spinning"); + syncLabel.textContent = "Syncing…"; + if (syncResult) syncResult.textContent = ""; + hideSyncError(); +} + +/* Settle the button back to clickable + un-spun with the given label. */ +function settleSyncButton(label) { + syncBtn.disabled = false; + syncBtn.removeAttribute("aria-busy"); + if (syncIcon) syncIcon.classList.remove("is-spinning"); + syncLabel.textContent = label; +} + +function applySyncSuccess(status) { + const time = fmtSyncTime(status.finished_at); + settleSyncButton(time ? `Synced ${time}` : "Synced"); + if (syncResult) syncResult.textContent = fmtSyncResult(status.detail); + hideSyncError(); + // The KB just changed — refresh the catalog live so the table, stats, + // and empty state never sit stale under the "Synced" label (the sync is + // the page's own action; a reload should not be needed to see it). + loadDocs(); +} + +function applySyncFailure(status) { + settleSyncButton("Sync sources"); // retry-ready + if (syncResult) syncResult.textContent = ""; + showSyncError(status.error); +} + +/* A run can only vanish with a server restart mid-sync (status resets + * to idle — the phase-accepted behavior): re-enable retry-ready with no + * banner (there is no error to name; the next click re-syncs). + * Idempotent — also the post-403 cleanup. */ +function applySyncIdle() { + settleSyncButton("Sync sources"); + if (syncResult) syncResult.textContent = ""; + hideSyncError(); +} + +/* The 2 s poll loop — the ONLY feedback timer (no client-side hard + * timeout, phase locked decision). One tick at a time (re-scheduled + * only while the run is still live, so an in-flight fetch can never + * overlap the next tick), and startSyncPolling refuses to run a second + * loop (a 409 adoption or a reload never doubles the polling). */ +function startSyncPolling() { + if (syncPollTimer !== null) return; + const tick = async () => { + let status = null; + let notAdmin = false; + try { + const r = await fetch("/api/sync/status"); + if (r.status === 403) notAdmin = true; + else if (r.ok) status = await r.json(); + } catch { + /* network blip — the next tick retries (no client timeout to trip) */ + } + if (notAdmin) { + // Session lost mid-sync: defense in depth — hide the button. + stopSyncPolling(); + syncBtn.hidden = true; + applySyncIdle(); + return; + } + if (status && status.state === "success") { + stopSyncPolling(); + applySyncSuccess(status); + return; + } + if (status && status.state === "failed") { + stopSyncPolling(); + applySyncFailure(status); + return; + } + if (status && status.state === "idle") { + // The run died with a server restart — retry-ready, no banner. + stopSyncPolling(); + applySyncIdle(); + return; + } + syncPollTimer = setTimeout(tick, SYNC_POLL_MS); + }; + syncPollTimer = setTimeout(tick, SYNC_POLL_MS); +} + +/* Click → POST /api/sync. 202 starts the run; 409 adopts the in-flight + * one (started elsewhere — e.g. a second tab); 403 hides the button + * (defense in depth); anything else names the failure in the banner and + * leaves the button retry-ready (the never-stale contract). */ +async function startSync() { + let r; + try { + r = await fetch("/api/sync", { method: "POST" }); + } catch { + showSyncError("Could not reach the server to start the sync — try again."); + return; + } + if (r.status === 403) { + stopSyncPolling(); + syncBtn.hidden = true; + applySyncIdle(); + return; + } + if (r.status === 202 || r.status === 409) { + enterRunningState(); + startSyncPolling(); + return; + } + let detail = ""; + try { + detail = (await r.json()).detail || ""; + } catch { + /* non-JSON error body */ + } + showSyncError(detail || `The server refused to start the sync (${r.status}).`); +} + +/* Load-time re-attach (admin only — the IIFE runs this after the + * whoami gate): a running run re-enters the running state (the user may + * have reloaded mid-sync), a terminal run renders its last result, idle + * renders nothing. */ +async function initSyncButton() { + if (!syncBtn) return; + let status; + try { + const r = await fetch("/api/sync/status"); + if (r.status === 403) { + syncBtn.hidden = true; // defense in depth + return; + } + if (!r.ok) return; + status = await r.json(); + } catch { + return; // network blip — the button stays idle and clickable + } + if (status.state === "running") { + enterRunningState(); + startSyncPolling(); + } else if (status.state === "success") { + applySyncSuccess(status); + } else if (status.state === "failed") { + applySyncFailure(status); + } + /* idle → nothing to render */ +} + +if (syncBtn) syncBtn.addEventListener("click", startSync); + (async () => { await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar if (!(await isAdmin())) { @@ -158,4 +388,5 @@ function showEmpty() { } if (gateEl) gateEl.hidden = true; loadDocs(); + initSyncButton(); // phase 32: re-attach to a running / last sync run })(); diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 292800c..a31bcc8 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -313,6 +313,48 @@ html::after { .auth-link:disabled { opacity: 0.6; cursor: wait; } .auth-link svg { width: 16px; height: 16px; display: none; } +/* Phase 32: the admin-only "Sync sources" pill (Sources header) — the + same ghost pill as New chat / the auth links, so the bar keeps one + visual language. ink-soft on surface ≈6.9:1 (WCAG AA); hover pair + brand-ink/brand-soft ≈6.9:1. The refresh icon is always visible (it + doubles as the running-state spinner); icon-only below 640px like + the other pills (aria-label keeps the accessible name). ≥44px touch + target at every width; :focus-visible via the global rule. */ +.sync-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-height: 44px; + padding: 0.5rem 0.9rem; + border-radius: 999px; + border: 1px solid var(--line); + background: transparent; + color: var(--ink-soft); + font: inherit; + font-weight: 600; + font-size: 0.95rem; + white-space: nowrap; + cursor: pointer; +} +.sync-btn:hover { background: var(--brand-soft); color: var(--brand-ink); } +.sync-btn:disabled { opacity: 0.6; cursor: wait; } +.sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; } +/* Running state: the refresh icon spins (reuses the shared spin + keyframes) — the visible half of "Syncing…" while the 2 s poll waits. */ +.sync-btn .sync-icon.is-spinning { animation: spin 1s linear infinite; } +@media (prefers-reduced-motion: reduce) { + .sync-btn .sync-icon.is-spinning { animation: none; } +} +/* The aria-live last-result announcer ("2 added · 1 pruned") — soft ink + on the header surface (≈6.9:1), small mono to match the stat cards. */ +.sync-result { + color: var(--ink-soft); + font-family: var(--mono); + font-size: 0.8rem; + white-space: nowrap; +} + /* "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. @@ -1598,6 +1640,23 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } .auth-link { padding: 0.4rem 0.55rem; } .auth-label { display: none; } .auth-link svg { display: block; } + /* Phase 32: the sync pill goes icon-only like the other pills (the + aria-label keeps the accessible name); the spinning icon is the + visible running state on a touch screen. */ + .sync-btn { padding: 0.4rem 0.55rem; } + .sync-label { display: none; } + /* The last-result counts stay ANNOUNCED (aria-live is untouched) but + go visually hidden — the 58px bar has no room for the text; the + icon carries the visible state. Same clip recipe as .steering-label. */ + .sync-result { + position: absolute !important; + width: 1px; height: 1px; + margin: -1px; padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } .steering-toggle { padding: 0.4rem 0.55rem; } /* Visually hidden, NOT display:none — the accessible name keeps the word "Tuning" next to the count badge. */ diff --git a/frontend/sources.html b/frontend/sources.html index c7d6d77..b55f761 100644 --- a/frontend/sources.html +++ b/frontend/sources.html @@ -37,6 +37,20 @@ New chat + + +