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.
225 lines
9.3 KiB
Python
225 lines
9.3 KiB
Python
"""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 sources" action — phase locked
|
|
decisions):
|
|
|
|
1. verify ``embed`` + summary model availability — fail fast before
|
|
any clone (:func:`app.rag.llm.check_models`, phase 41): a dead
|
|
model endpoint aborts the run naming the unavailable model, before
|
|
source resolution or any ``clone_or_pull``;
|
|
2. resolve the effective sources — the ``git_sources`` DB rows (git
|
|
**and** local, phase 38), else the ``BOR_GIT_SOURCES`` fallback
|
|
(git-only)
|
|
(:func:`app.rag.git_sources.effective_sources`, shared with the
|
|
CLI) — empty on both origins (no git rows, no local rows, no env
|
|
URLs) fails loudly (``no sources configured (git or local)``)
|
|
instead of silently importing the legacy local directories;
|
|
3. per resolved row: ``kind=git`` → :func:`scripts.git_sync.clone_or_pull`
|
|
into ``BOR_SOURCES_DIR/<repo-name>/`` (phase 28 — reused, not
|
|
re-implemented); ``kind=local`` → the stored directory, re-verified
|
|
``.is_dir()`` **at sync time** (it may have moved/deleted since
|
|
add-time) — a missing directory raises ``local source missing:
|
|
<path>``; a failing clone or a missing local dir aborts before any
|
|
import;
|
|
4. ``import_sources(..., prune=True)`` over the single combined list
|
|
(git checkouts + local dirs) — prune so files deleted upstream or
|
|
out of a local dir leave the index (pruning covers the union; the
|
|
CLI's no-prune default is unchanged);
|
|
5. 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.db import SessionLocal
|
|
from app.rag.git_sources import effective_sources
|
|
from app.rag.importer import ImportSummary, import_sources
|
|
from app.rag.llm import LLMClient, check_models
|
|
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()
|
|
# Step 1 (phase 41): fail fast — verify both models the sync
|
|
# needs (embed + summary) before source resolution or any
|
|
# clone. The client is reused for the import + overview below.
|
|
llm = LLMClient()
|
|
await check_models(llm)
|
|
# The background task has no request session: open a short-lived
|
|
# one around the shared phase-35/38 resolver (DB rows of both
|
|
# kinds win; the BOR_GIT_SOURCES git list is a fallback while
|
|
# the table is empty).
|
|
db = SessionLocal()
|
|
try:
|
|
rows, origin = effective_sources(db)
|
|
finally:
|
|
db.close()
|
|
if not rows:
|
|
# The button targets the admin-managed source registry
|
|
# (manual --source dirs have no repo to clone) — an empty
|
|
# config on *both* origins (no git rows, no local rows, no
|
|
# env URLs) fails loudly instead of silently importing the
|
|
# legacy directories.
|
|
raise GitSyncError("no sources configured (git or local)")
|
|
git_count = sum(1 for row in rows if row.kind == "git")
|
|
logger.info(
|
|
"sync: started repos=%d origin=%s git=%d local=%d",
|
|
len(rows), origin, git_count, len(rows) - git_count,
|
|
)
|
|
sources_root = Path(settings.sources_dir).expanduser()
|
|
sources: list[Path] = []
|
|
for row in rows:
|
|
if row.kind == "git":
|
|
sources.append(clone_or_pull(row.url, sources_root / repo_name(row.url)))
|
|
else:
|
|
# kind=local — the stored expanded path (phase 38 also
|
|
# mirrors it in the NOT-NULL ``url`` location column, the
|
|
# ``or`` keeps the type checker honest); re-verified at
|
|
# sync time because the directory may have moved or been
|
|
# deleted since add-time.
|
|
path = Path(row.path or row.url).expanduser()
|
|
if not path.is_dir():
|
|
raise GitSyncError(f"local source missing: {path}")
|
|
sources.append(path)
|
|
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))
|