feat(sources): removing a source deletes its files and index entries behind a confirmation modal
This commit is contained in:
+127
-12
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user