feat(admin): one-click sources sync — admin-only button triggers git clone/pull + re-import + KB overview refresh with polled live status
This commit is contained in:
@@ -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://<fixture repo>`, 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).
|
||||
Reference in New Issue
Block a user