Files
brain-of-reese/.agent/user_stories/admin-sync-button.md
T

5.3 KiB

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).