feat(sources): removing a source deletes its files and index entries behind a confirmation modal
Build and Push Containers / build-and-push-app (push) Successful in 1m29s
Build and Push Containers / build-and-push-db (push) Successful in 11s

This commit is contained in:
2026-09-02 15:55:33 -04:00
parent 265e736b3d
commit 137d5fa1a5
24 changed files with 3489 additions and 118 deletions
+127 -12
View File
@@ -28,8 +28,10 @@ task** — see :func:`upload_archive` and :func:`_run_upload`),
state of that run — incl. the phase-64 ``current_file`` /
``files_done`` / ``files_total`` progress fields; navigating away from
the page mid-scan no longer aborts anything), ``DELETE /{source_id}``
(204). The whole router sits behind :func:`app.core.auth.require_admin`
— anonymous callers get 403 on every route.
(204 — total removal, phase 69: row + the source's documents (chunks +
embeddings) committed first, then the app-managed on-disk dir). The
whole router sits behind :func:`app.core.auth.require_admin` —
anonymous callers get 403 on every route.
No credential-echo path: git URLs may embed ``user:pass@`` (phase 32's
masking discipline), so every git 409/422 detail is a fixed generic
@@ -38,9 +40,20 @@ secrets — the local 422/409 details name the (expanded) path so the
owner sees exactly which directory failed.
Scope boundary (phase locked decisions): the CRUD routes do NOT
clone, import, or prune anything — the existing Sync button performs
that (a removal prunes on the next sync, ``prune=True``). The upload
route is the exception (phase 64, task 03): after the 202 receive
clone or import anything — the existing Sync button performs that, and
its ``prune=True`` stays for **upstream file churn** (files deleted
upstream or dropped from a local dir), not for row removal: ``DELETE``
is a total removal in itself (phase 69) — the row + the source's
documents (chunks + embeddings via the ``all, delete-orphan`` cascade)
commit atomically **first** (the RAG is always consistent with the
registry), then the app-managed on-disk dir (git checkout / unpacked
upload folder) is deleted; an ``OSError`` there is logged, never fatal.
Foreign local dirs (the owner's own) are never touched on disk, a
sibling row sharing the source name keeps the shared documents +
files (only the row goes), and a pruned KB bumps ``sources_version``
exactly once (the phase-53 saved-chat invalidation) with a
best-effort overview refresh. The upload route is the other exception
(phase 64, task 03): after the 202 receive
answer, its background task unpacks the archive, swaps it in, upserts
the row, probes the models, scans the single source
(``import_sources`` with ``prune=True`` + the change-gated overview
@@ -69,7 +82,7 @@ from app.api.sync import _sanitize_error
from app.config import get_settings
from app.core.auth import require_admin
from app.db import SessionLocal, get_db
from app.models import GitSource
from app.models import Document, GitSource
from app.rag.archive_upload import (
ARCHIVE_SUFFIXES,
ArchiveUploadError,
@@ -80,6 +93,13 @@ from app.rag.archive_upload import (
from app.rag.importer import import_sources
from app.rag.llm import LLMClient, check_models
from app.rag.overview import regenerate_overview
from app.rag.source_removal import (
has_sibling,
managed_dir_for,
remove_managed_dir,
resolve_source_name,
)
from app.rag.sources_meta import bump_sources_version
from app.schemas import (
GitSourceIn,
GitSourceList,
@@ -564,18 +584,113 @@ async def _run_upload(
@router.delete("/{source_id}", status_code=204)
def delete_git_source(
async def delete_git_source(
source_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> Response:
"""Remove a stored row; 404 when the id is unknown.
"""Remove a stored source — **totally** (phase 69); 404 unknown id.
Removing does not touch the clones or the index — the next Sync
(``prune=True``) prunes the dropped repo (phase scope boundary).
Total removal, one action: the row, every indexed document of the
source (chunks + embeddings via the ``all, delete-orphan`` cascade
— ``app.models``), and, for app-managed sources, the on-disk dir
(the git checkout or the unpacked upload folder). Foreign local
directories (the owner's own) are never touched on disk — their row
+ index entries still go.
Locked order (phase 69):
1. **Sibling guard** — another stored row resolves to the same
source name (e.g. ``…/r`` and ``…/r.git``): it still owns the
shared documents + files, so only this row is deleted (logged
loudly); no index/disk work.
2. **DB first** — in the request transaction: the source's
documents are deleted, then the row, one ``commit``. A DB
failure propagates as 500 **before any disk work** — the 204
contract below never sees a half-removal.
3. **Disk second** — the app-managed dir is removed after the
commit; an ``OSError`` is logged, never fatal (a leftover dir
is inert and self-heals on re-add; the reverse order is
forbidden — a disk failure must never leave a row pointing at
deleted files). Foreign local dir → skipped.
4. **When documents were pruned** — the best-effort overview
refresh (an LLM failure logs, never fails the 204; the next
added/updated change refreshes it, as today) and then exactly
one ``sources_version`` bump (phase 53 — the bump lands even if
the overview failed, mirroring ``_run_sync``'s order). No docs
pruned → no overview, no bump.
The route contract is unchanged: 204 with no body; the per-
operation INFO line (PLAN §9) carries the counts —
``files_removed`` is ``yes|no|skipped`` (``skipped`` for the
sibling guard and for foreign local dirs).
"""
started = time.monotonic()
row = db.get(GitSource, source_id)
if row is None:
raise HTTPException(status_code=404, detail="git source not found")
db.delete(row)
db.commit()
name = resolve_source_name(row)
settings = get_settings()
managed = managed_dir_for(row, Path(settings.sources_dir), Path(settings.upload_dir))
if has_sibling(db, row):
# Sibling guard: the shared documents + files still belong to
# the sibling row — only this row goes (phase 69 locked
# decision), so no index prune and no disk work.
logger.warning(
"source removal: row %s (url=%s) shares source name %s with "
"another stored row — deleting only the row; the shared "
"documents and files stay",
row.id,
row.url,
name,
)
db.delete(row)
db.commit()
docs_pruned, files_removed, overview = 0, "skipped", False
else:
# DB first: prune every document of the source (the cascade
# drops all chunks incl. embeddings) and the row itself in one
# commit — the RAG is always consistent with the registry.
docs = db.scalars(select(Document).where(Document.source == name)).all()
for doc in docs:
db.delete(doc)
db.delete(row)
db.commit()
docs_pruned = len(docs)
# Disk second: only the app-managed dir (git checkout / upload
# folder) — ``managed is None`` is the foreign local dir (the
# owner's own), never touched on disk.
if managed is None:
files_removed = "skipped"
else:
files_removed = "yes" if remove_managed_dir(managed) else "no"
# KB changed (documents pruned) → best-effort overview refresh,
# then exactly one sources_version bump — the bump in its own
# short-lived session (the ``_run_sync`` pattern) so it lands
# even if the best-effort overview failed.
overview = False
if docs_pruned > 0:
try:
overview = await regenerate_overview(LLMClient())
except Exception: # noqa: BLE001 — best-effort: never fail the 204
logger.exception("source removal: overview regeneration failed (best-effort)")
overview = False
bump_db = SessionLocal()
try:
bump_sources_version(bump_db)
bump_db.commit()
finally:
bump_db.close()
logger.info(
"source removed: kind=%s name=%s docs_pruned=%d files_removed=%s "
"overview=%s total_ms=%d",
row.kind,
name,
docs_pruned,
files_removed,
overview,
round((time.monotonic() - started) * 1000),
)
return Response(status_code=204)