Phase 49 (owner request, chat 2026-08-28: "The git sources page should remove local directory and should instead accept a tarball or zipfile upload which it will unpack and scan … reuploading the same tarball should not create a new folder, but should unpack and overwrite the previously unpacked content" — design confirmed in the same conversation): * POST /api/git-sources/upload (admin-only, require_admin): accepts .tar/.tar.gz/.tgz/.zip, streams it with the BOR_UPLOAD_MAX_MB cap (bounds BOTH the compressed upload and the total extracted bytes — zip-bomb guard), safely unpacks (absolute/traversal/symlink/hardlink escape and device/FIFO members rejected), and atomically swaps the content in over BOR_UPLOAD_DIR/<name>/ (name = filename minus the archive suffix — no missing window, a failed upload never touches the existing folder/row/KB). The git_sources row is upserted by path (kind='local', no duplicates, added_at preserved), the models are checked fail-fast (503 sanitized when down — the folder/row stay committed and the next sync/re-upload retries idempotently), and the source is scanned synchronously in the request (single-source import_sources prune=True + change-gated KB overview), answering 200 with the sync-style counts. One upload at a time (409); the request session is released before the scan so a concurrent TRUNCATE cannot deadlock against it. * app/rag/archive_upload.py: ArchiveUploadError, ARCHIVE_SUFFIXES, archive_source_name (safe-name derivation), unpack_archive (guarded zip/tar extraction with the extracted-byte cap, no partial state), swap_in (atomic replace with restore-on-failure) — fully unit-tested. * app/config.py + .env.example: BOR_UPLOAD_DIR (default ~/bor-sources/uploads, deliberately separate from the git checkouts) and BOR_UPLOAD_MAX_MB (default 512; a validator fails loud at startup on <= 0). * python-multipart added to the dependencies — FastAPI's required multipart parser (an A2 implementation detail, phase locked decision). * The Sources page: the phase-38 "Add a local directory" form is removed; #archive-upload-form takes its place (labeled file input, "Upload & scan" button, the §7.4 never-stale lifecycle, inline role=alert error, role=status count line); hint + table caption updated. The POST /api/git-sources kind=local API contract is UNCHANGED — a plain directory is still registrable via the API, and existing Local rows list/remove/sync exactly as before. * The phase-38 story E2E (test_local_directory_sources.py) is rewritten API-driven — the form it drove is gone; its acceptance stands. * The story E2E (test_archive_upload_sources.py): the swap, upload→scan→list (the deterministic "Uploading…" in-flight state, the Local row, /api/docs + the RAG catalog), same-filename re-upload (in-place replace, prune, no duplicate row, v2-only folder), the 422 inline error + recovery (the form is not wedged), and the anonymous gate + 403. * README: the archive-upload section (formats, naming rule, in-place replace, both new settings), the local-directory form removal noted, config reference rows for BOR_UPLOAD_DIR / BOR_UPLOAD_MAX_MB. Gates: unit+integration green, app/ coverage 99%, the story E2E green in isolation, the regression suites (git sources admin, local directory sources, sync button, import documents, nav rename, smoke, shared header) green in isolation, ruff + pyright clean. Note: per this phase's file-level staging, frontend/assets/styles.css also carries the small same-day in-flight owner rework already in the working tree (the .sign-in-mobile companion rule for the phase-48 mobile sign-in copy); the phase-49 change is the upload form's block.
9.1 KiB
Phase 49 — Archive upload sources (tarball/zipfile → unpack → scan)
Source: owner request (chat, 2026-08-28) — "The git sources page should remove local directory and should instead accept a tarball or zipfile upload which it will unpack and scan. Note that reuploading the same tarball should not create a new folder, but should unpack and overwrite the previously unpacked content." (Design confirmed by the owner in the same conversation.)
Story: .agent/user_stories/archive-upload-sources.md
Context: 35_git_sources_admin (complete) — the git_sources table (id, url, kind, path, added_at), the admin-only /api/git-sources router, and the /git-sources.html manager page; 38_local_directory_sources (complete) — kind='local' rows the Sync pipeline and import_docs walk directly, plus the page's "Add a local directory" form this phase removes; 32_admin_sync_button + 41_sync_fail_fast_models (complete) — the in-process pipeline parts this phase reuses: check_models fail-fast, import_sources(sources, llm, prune=True) (source name = folder basename, per-file transactions, per-source prune), regenerate_overview, and the sync-detail count keys (files, added, updated, unchanged, pruned, errors, chunks, overview); 16_admin_auth (complete) — the require_admin router dependency the new route inherits.
Objective
Replace the local-directory form on the Sources page with an archive upload form: POST /api/git-sources/upload accepts .tar/.tar.gz/.tgz/.zip, unpacks it safely into BOR_UPLOAD_DIR/<name>/ (name = filename minus the archive suffix), atomically swaps it in when the name already exists, upserts the git_sources row (kind=local, no duplicates), and scans it — single-source import_sources(prune=True) + overview refresh — returning the sync-style counts. Re-uploading the same filename overwrites the previous content in place: one folder, one row, dropped files pruned from the KB.
Dependencies
48_nav_rename_sources(todo — runs first) — sequential only: it relabels the nav in the samegit-sources.htmlthis phase edits (keeps the diffs clean).35_git_sources_admin(complete) — the table/API/page this phase extends; therequire_adminrouter; theIntegrityError → 409backstop pattern (_commit_new).38_local_directory_sources(complete) — thekind=localrows uploads register; the local form removed; the phase-38 story E2E rewritten in this phase.32_admin_sync_button/41_sync_fail_fast_models(complete) —check_models+import_sources+regenerate_overview+ the count-key contract the upload response mirrors.16_admin_auth(complete) — admin-only surface (A10 revision).
Tasks
01_settings_and_unpack_utility.md—BOR_UPLOAD_DIR+BOR_UPLOAD_MAX_MBsettings,.env.example, and the newapp/rag/archive_upload.py(name derivation, safe tar/zip unpack with traversal/symlink/size guards, atomic swap-in) + unit tests.02_upload_api.md—python-multipartdependency +POST /api/git-sources/upload(stream-with-cap, one-at-a-time 409, upsert row, fail-fast models, single-source scan, sync-style 200 body, log line) + integration tests incl. re-upload/overwrite and no-partial-state.03_admin_page_upload.md— the page: local form out, upload form in (§7.4 lifecycle, result line, hint/caption) + the phase-38 story E2E rewritten API-driven.04_story_e2e_docs_commit.md— the story E2E (test_archive_upload_sources.py), README, regression suites in isolation, the one--no-gpg-signcommit, and the phase-dir move.
Testing & Quality
- Unit:
tests/unit/test_archive_upload.py— the name-derivation matrix (suffix stripping incl. double.tar.gz; empty/../separator/control-char rejection); safe unpack (valid zip + tar; zip-slip../; absolute member; symlink + hardlink escape; device member; extracted-byte cap);swap_in(fresh, replace-existing with full content replacement, failure leaves the previous folder intact). - Integration:
tests/integration/test_git_sources_upload.py— anonymous 403 on the new route; 422 (bad extension, unsafe/empty name, traversal archive, corrupt archive); 413 (compressed cap, via the settings-override pattern oftest_git_sources_api.py); 409 (second upload while the first is in flight); 200 happy path (real temp tarball, counts correct, rowkind=localunderupload_dir, docs in the KB); re-upload same name (one row, old folder content fully replaced, dropped file pruned, new file indexed); failed re-upload leaves the previous folder + row + KB untouched. The existingtest_git_sources_api.py/test_sync_api.py/test_import_docs_git.pysuites stay green through the change (thekind=localPOST contract is untouched). - Coverage: >90% on
app/— the new module + endpoint fully covered. - E2E (mandatory, A16):
tests/e2e/test_archive_upload_sources.py, run in isolation.
Completion Criteria
uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missing>90%.- Uploading
homelab.tar.gzvia the page: unpacked underBOR_UPLOAD_DIR/homelab/, scanned (result line shows the counts), one list row (Local badge, namehomelab), documents visible on/sources.html. - Re-uploading
homelab.tar.gz(modified): still exactly one folder and one row; dropped files pruned from the KB; added/changed files indexed. - Non-archive file → inline 422; oversized → 413; zip-slip/tar-slip archive → 422 with the previous folder/row/KB untouched; second concurrent upload → 409.
#local-source-formis gone from the page; the phase-38 story E2E green in isolation, API-driven; anonymous still gets the gate.uv run pytest tests/e2e/test_archive_upload_sources.py -v --no-covgreen in isolation (DB up).- Regression E2E suites green in isolation:
test_git_sources_admin.py,test_local_directory_sources.py,test_sync_button.py,test_import_documents.py,test_nav_rename_sources.py(when 48 is complete),test_smoke.py. - README +
.env.exampledocument the upload (formats, naming, in-place replace, both new settings); ruff + pyright clean. - UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6).
- One
--no-gpg-signcommit; phase directory moved to.agent/phases/complete/.
Locked decisions (owner permission 2026-08-28 — the confirmed design)
- Formats:
.tar,.tar.gz,.tgz,.ziponly (422 naming the accepted set otherwise). - Identity & in-place replace: the source name is the uploaded filename minus the archive suffix (
homelab.tar.gz→homelab, case-sensitive — Linux FS). The name determines the folder underBOR_UPLOAD_DIR; re-uploading the same name unpacks to a temp sibling and renames it over the existing folder (no missing window; a failed upload never touches the existing folder, row, or KB). No second folder, no second row — thegit_sourcesrow is upserted bypath(kind='local', reusing the phase-38 discriminator — no migration, no new table; A13 honoured). - New settings:
BOR_UPLOAD_DIR(default~/bor-sources/uploads— deliberately separate from the git checkouts inBOR_SOURCES_DIR) andBOR_UPLOAD_MAX_MB(default 512) capping BOTH the compressed upload and the total extracted bytes (zip-bomb guard). - The scan is synchronous in the upload request (owner-confirmed): fail-fast
check_models(phase 41) →import_sources([folder], llm, prune=True)(single source) →regenerate_overviewwhen the KB changed → 200 with the sync-detail count keys so the page renders the same "N added · N pruned" line. One upload at a time — 409 while a run is in flight (the phase-32 pattern). - Unpack safety: absolute member paths,
..traversal, symlink/hardlink targets escaping the unpack folder, and device/FIFO members are rejected (422); extracted bytes are counted against the cap while writing. - Page: the "Add a local directory" form is removed; the
POST /api/git-sourceskind=localAPI contract is unchanged (admin can still register a plain directory via the API — no regression; existing Local rows still list/remove, and the Sync button +import_docskeep walking them). - No auto-unwrap of a single top-level folder — files land in the KB exactly as packed (documented in the hint/README).
python-multipartis added to the dependencies — FastAPI's required multipart parser for file uploads (an A2 FastAPI implementation detail, not a new architectural anchor; recorded here per AGENTS.md rule 3).- Boundaries (deliberately out of scope): page
<title>/<h1>rename (flagged in phase 48); background/202 upload runs (synchronous locked above); deleting the uploaded archive bytes (temp file removed after unpack — only the unpacked content is kept); cross-kind source-name collisions with a git repo of the same folder name (pre-existing importer behavior, unchanged); coordinating an in-flight full Sync with an upload (accepted edge — per-file transactions + per-source-name prune keep the KB consistent). - A10 / A11 / A16 / A17 honoured — admin-only surface (no new session state), vanilla frontend (no CDN), one story E2E, one atomic
--no-gpg-signcommit.