Skip to content

Add self-hosted Docker server with media search API and web UI - #184

Open
Ocean572 wants to merge 68 commits into
ARahim3:mainfrom
Ocean572:feat/server
Open

Ocean572 wants to merge 68 commits into
ARahim3:mainfrom
Ocean572:feat/server

Conversation

@Ocean572

@Ocean572 Ocean572 commented Nov 3, 2026 •

Copy link
Copy Markdown

Add self-hosted Docker server for DigUp

This PR introduces a self-hosted Docker server for DigUp, enabling users to search and browse their media library via a lightweight web interface or REST API.

The server operates as an adapter around the existing DigUp engine rather than implementing a separate search system, ensuring consistent search behavior across desktop, CLI, and server environments.

Closes #183


Key Features

  • REST API: FastAPI endpoints under /api/v1.
  • Web Interface: Lightweight HTML, CSS, and vanilla JS UI (no external frontend frameworks).
  • Media Search & Previews: Delivers engine-native search results, relevance scores, and streaming media previews with HTTP range request support.
  • Background Indexing: Includes progress tracking, single-writer locking, and failure recovery (interrupted jobs log state accurately and resume seamlessly on the next run).
  • Docker Deployment: Multi-stage Dockerfile and docker-compose setups. Data paths (index, models, config, job logs) use bind mounts to ensure container recreation never destroys index state.
  • Published Registry Images & GPU Acceleration: CI publishes CPU (:latest) and CUDA (:latest-cuda) images to GHCR with pre-configured Compose override files. Opt-in NVIDIA GPU acceleration is measured and validated on physical hardware.

Once running, the interface is accessible at:
http://127.0.0.1:8787/


How It Works

The server delegates all indexing and search logic to the underlying DigUp engine via its published interfaces (search-service/index-service JSON-lines protocols, standard CLI, and --json output).

  • No Engine Modifications: Search behavior, relevance scoring, and ranking remain unpatched and un-mocked.
  • Parity Verified: Identical search results, z-scores, and strong-result counts were confirmed between the desktop application's SearchService and the HTTP API on identical database snapshots.
  • GPU Setup: All GPU configurations are handled strictly at the environment and container build level without altering engine code.

Testing & Performance Benchmarks

All performance metrics were gathered on physical Linux x86_64 hardware (CI hosted runners and a host running a Ryzen 9 7950X, 64 GB RAM, GTX 1070).

1. Automated Test Suite

  • 102 passed, 0 skipped (CI strictly fails on any skipped test).
  • Coverage: Search contracts, API endpoints, indexing job locks/recovery/rotation, media streaming, UI components, CLI integration, and startup prewarm.
  • Cross-Platform Fixes: Cross-architecture embedding math variances (pinned invariant file sets over platform-specific order) and a warm-machine timing race condition (adjusted stop window to 1 s) were addressed during x86_64 baseline validation.

2. CI Pipeline (server.yml)

  • Fully green pipeline completion (12m 26s execution time).
  • Validated multi-stage build (swift test), CLI headless checks, contract tests, search evaluations (R@1 0.76 / R@5 0.88), Compose schema checks, and graceful SIGTERM container termination (exit 143).
  • Includes an upstream model-pin drift guard that diffs download pins against Sources/DigUpKit/ModelFiles.swift.

3. Docker & GPU Validation

  • Images Built: CPU image (568 MB), CUDA 12.4.1 image (3.67 GB, supporting compute capabilities 6.1–9.0).
  • GPU Acceleration (Pascal GTX 1070):
    • Single Image Embed Time: 0.146 s (GPU) vs. 1.485 s (CPU) (cosine agreement: 0.99972).
    • Utilization: Peak 96% utilization, active during 37% of index duration, with model VRAM footprint held at 1780 MiB.

4. Large-Scale Library Benchmark (131,972 Indexable Files)

Validated against a 187,055-file real-world library on USB storage over three sequential jobs:

  • Resiliency Proof: Successfully survived and resumed across three full stop/resume cycles (ceiling timeout interrupts preserved index integrity and resumed clean via POST /api/v1/index). Single-writer locks correctly returned 409 on concurrent index requests.
  • Total Index Time: 16h 39m total engine runtime (averaging 2.195 files/s overall, peaking at ~15 files/s on images). This ran at 33.75% of the engine's original estimated forecast duration.
  • Final Index Metrics: 131,972 total files processed (131,758 searchable, 403,831 vectors, 1.39 GB total index size). Query latency on the fully indexed library measured 0.69–0.80 s across sampled test searches.

Continuous Integration & Delivery

The server.yml GitHub Actions workflow triggers on modifications to server code, deployment files, engine dependencies, or scripts:

  1. Builds and executes test suites in the builder container.
  2. Runs end-to-end headless search evaluation tests.
  3. Schema-validates all 7 Compose configuration merge variants.
  4. Publishes :latest (CPU) and :latest-cuda tags to ghcr.io for main branch updates, as well as feature-branch tags (:feat-server / :feat-server-cuda). Unused interim tags are routinely cleaned up via registry hygiene steps.

Known Limitations

  • Localhost Only by Default: No built-in auth. Exposing the server to a local network requires the explicit docker-compose.lan.yml override. Public internet usage requires an authenticating reverse proxy.
  • Architecture Support: Pre-built registry images are published for x86_64 only. Other architectures (e.g., Apple Silicon) build natively via docker compose up -d --build.
  • GPU Driver Scope: GPU acceleration is currently NVIDIA-only (cuda). AMD/Vulkan setups are left for future PRs.
  • Single-User Architecture: Designed for personal, single-tenant deployments.

Quickstart Guide

Option 1: Local Build (Any Architecture)

cd deploy
docker compose up -d --build

Option 2: Pre-built CPU Image (x86_64)

cd deploy
DIGUP_INDEX_DIR=/path/to/index \
DIGUP_MODELS_DIR=/path/to/models \
DIGUP_MEDIA_DIR=/path/to/media \
DIGUP_IMAGE_TAG=latest \
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml up -d

Option 3: Pre-built NVIDIA GPU Image

DIGUP_IMAGE_TAG=latest \
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml \
  -f docker-compose.ghcr.nvidia.yml --profile nvidia up -d

Permissions Note for Linux: Ensure bind directories are readable/writable by the container user:
mkdir -p ./{index,models,config} && sudo chown -R 990:990 ./{index,models,config}

Option 4: Home LAN Exposure (Unauthenticated)

DIGUP_LAN_BIND=<HOST_LAN_IP> \
docker compose -f docker-compose.yml -f docker-compose.lan.yml up -d

Access the UI at http://127.0.0.1:8787/. Trigger indexing manually via the UI or POST /api/v1/index.


PR Footprint

  • Files Changed: 79 files added (+11,195 lines, -0 lines).
  • Engine Modifications: 0 changes to core engine source files or existing workflows (100% additive PR).
  • Extended documentation and benchmark logs can be found in server/IMPLEMENTATION_STATUS.md and docs/implementation/.

…corpus

The server's wrapper (docs/implementation/) starts from the engine itself: a Linux
image built from this repo's own scripts (vendor, llama.cpp, ffmpeg), the CLI
verified headless in the final image, and Stage 2's proof indexing the pinned
Commons corpus and answering natural-language searches with the engine's numbers.
Additive: only .gitignore gains lines, and .dockerignore keeps host artifacts out
of the build context.
The server talks to DigUp the way the Tauri shell does: spawn digup search-service /
index-service / worker as supervised children (the ParentWatch rule), speak their
JSON-lines protocols, read status/estimate from the CLI. The engine is never linked,
patched, or replaced; the contract tests pin every reply field, event, and exit path
the adapter uses, against the shipped Linux payload.
POST /index starts one serialized job (409 busy with the live id); the runner forwards
the engine's stop at the safe point, marks interrupted records with partial counts,
replays its append-only log across rotations at startup, and reloads+prewarms the
searcher after a done job. Root-scope semantics pinned by tests: plain sync retires
walked-root ghosts; prune retires out-of-scope ghosts.
FastAPI with typed wire models, the engine's answers verbatim (hits, strong, meaning,
ms, warning, the honest empty), engine failures as 5xx quoting the engine, config from
the environment only — paths never arrive from a client. localhost by default; in the
container the publish line enforces that. The lifespan owns the search-service as a
direct child, prewarms at startup, and ends every helper on shutdown.
… by id

Vanilla HTML/CSS/JS served same-origin (no build step, no external URLs, pinned by
test). Media endpoints take only server-issued media_id handles: /preview (the
engine's render JPEGs, symlink-safe to <index>/previews), /text (the passage it
indexed), and /file — the file's real bytes, typed by magic bytes, Accept-Ranges and
206 for video seeking (#t=), inline for media kinds, attachment text/plain for
code/doc. The UI mirrors shell/ui: keyword-first typing (prefix_last words, then the
meaning upgrade after 120 ms with embed_ahead), strong/weaker fold, kind filters,
code mode, limit, selection with arrows, Space big look, Open at the video's moment,
Estimate panel, activity dot, keys footer — plus the System/Light/Dark appearance
choice kept in localStorage, which the OS-following desktop needs no equivalent of.
Settings that are OS integrations are documented as deployment env config; reveal
and copy are absent, not faked.
server-runtime: engine payload + API + UI on python:3.12-slim, tini as PID 1 (the
ParentWatch chain tini->uvicorn->helpers), non-root uid 990, HEALTHCHECK asking the
API's own /health, startup prewarm. Compose publishes 127.0.0.1:8787, names its
project digup-server, keeps index/models/config/job-log in bind-mounted volumes, and
sets stop_grace_period: 90s so a mid-job docker stop finishes the safe-point path
(143, interrupted with partial counts) — the 15s default's SIGKILL path recovers by
log replay and resumes, both measured (docs stage 8).
The suite's corpus stays exactly 15 files (its rankings normalize over that set), so
new test material lives beside it: 20 subjects x 3 Commons photographs fetched as
1400 px renders, pinned in manifest.tsv, out of git, built by build.sh; the H.264
scenes clip travels with it. Provenance and licenses documented; it is testing
material for the running server (Compose mount note in its README), not suite input.
…unbook docs

.github/workflows/server.yml: build the builder and runtime images, run swift-side
payload checks, materialize the corpus, download the pinned models, run the contract
suite against the shipped payload, validate the Compose schema, and assert the SIGTERM
contract (exit != 137, shutdown complete). The drift-guard step compares
ModelFiles.swift's sha256 constants with the workflow's download pins, so an upstream
model bump fails CI loudly before the server can silently run a different model.
server/README.md and IMPLEMENTATION_STATUS.md carry the runbooks, the merge policy,
and what is proven.
…ettings

Palettes explicit per theme (measured WCAG ratios), color-scheme per theme,
the big-look overlay guarded (#big[hidden]) — it sat at 78% black over the
whole page. Search box centered with the hit-limit select at its right end,
kind chips as a themed, evenly spaced pill row, gear opens the settings
panel: appearance, Index now + prune, Estimate (one honest note line), and
the engine's counts. el() takes real child nodes (the [object HTMLElement]
text is gone); statusline shows the searchable count by the title.
…e; MP4 by ftyp

An ok:false reply (SearchService.swift:106,115) is EngineRefused: raised in the
adapter, answered 503 with the engine's sentence — a refusal reply has no hits,
and api.py used to crash KeyError on it (measured 500s in the Stage-10 battery).
raw() keeps passing the engine's replies verbatim; the runner survives a refused
post-job refresh (the job stays done, with a note). The UI ships
cache-control: no-cache — measured: without it Chrome served a stylesheet the
server no longer had. The magic sniff reads ftyp at offset 4 and the brand
there, so scenes.mp4 is video/mp4, not octet-stream.
…d fixes

Corpus2 gallery served live: job 0.4 s idempotent (61 files, 65 vectors), six
meaning searches all strong 3 (top z 4.46), /file 200·206·416·404 on real
bytes, video range + preview frame, estimate ≈ 263.9 s calibrated. Suite:
57 passed, 1 deselected, 241.18 s. The proof battery gates on readiness and
retries 503s. .gitignore: the Playwright MCP working folder.
Single-column system: header grid (title left, gear right, the searchable
count centered), search row 1fr auto 1fr so the input's center IS the page's
center with limit + code at its right end, footer text aligned to the content
column, settings anchored under the gear (right edges equal, controls left
edges at one x) as a label/control grid with section rules and a close
button. The gear is an inline SVG centered 0/0 in its circle, colored by
currentColor. Measured at 1280 px and 390 px: center offsets 0, overflowX 0.
The gear's svg broke the outside-click close (target !== gear) — closest().
Suite (20): 57 passed, 1 deselected, 236.12 s.
Nine real findings from the adversarial backend review, each fixed with the
scenario the reviewer described pinned as a test:

- jobs.shutdown() self-deadlocked (a plain Lock re-taken by _mark from inside
  its own with-block): a docker stop with a job past the join grace hung the
  lifespan forever. The active job is now read under the lock and marked
  outside it.
- the runner thread died on a TimeoutError from the post-job prewarm (not a
  RuntimeError): _active stayed set, every later POST /index answered 409
  naming a job that could never run. Any escape from _run_one now marks the
  job interrupted, clears the writer, and keeps the thread alive.
- a failed search-service handshake leaked the child (pipes + reader threads
  unreferenced until its 600 s idle-exit); _spawn_locked reaps on every
  failure and maps a missing binary to CLIError, so the API quotes the spawn
  as a 502 instead of dying as a 500.
- ok:false WITHOUT an error field passed the refusal guard and hit the API's
  stripped["hits"] as a KeyError — the guard fires on the shape now.
- render held the serialize lock across the engine's ffmpeg work, undoing the
  engine's own design (render runs beside the read loop on two slots, replies
  routed by id, "typing never waits on one"). Two slots, lock-free render:
  a 1.2 s frame no longer freezes keystroke-searches.
- Range: bytes=0- (what every media element sends first) buffered the whole
  file; open/suffix/huge ranges are capped at 8 MiB and streamed from the
  span's own handle. Type, size and span come from one open (fstat + S_ISREG),
  root confinement is is_relative_to, and every /file answer carries nosniff.
- subprocess.TimeoutExpired is a SubprocessError, not a TimeoutError: mapped
  in cli.run so a stalled status/estimate quotes the engine (502), not a
  traceback.
- job-log rotation shifted a .1…N chain (O(n) renames, order scramblable by a
  kill mid-shift: a running line read after its done reported finished jobs
  as interrupted). One rename to a time_ns suffix; replay is a sort. Replay
  skips valid JSON that is not a job record (5, null, a record with no id
  used to brick app start).
- HitCache.get() didn't refresh, so eviction was insertion-order: a browsing
  session evicted its own on-screen handles after ~26 searches (404s, no
  recovery). move_to_end makes it the LRU the docstring promised.
- /status and /health each spawned a digup status (real SQLite counting) per
  poll; the identity fields ride a StatusCache TTL (DIGUP_SERVER_STATUS_TTL_S,
  0 = read every time). SearchReply is extra="allow": engine fields this
  model has never seen are published, not dropped.

Suite: 75 passed, 1 deselected, zero skipped in 228.12 s in digup-server-test:local.
…yncs its aria

The UI review's blockers, verified in a live browser against the running stack:

- Space hijacked every button. Native activation fires on keyup; the document
  handler (Quick Look) preventDefault()ed on keydown for everything except a
  few tags, so the gear, the close, Index now, Estimate, "all", and the
  Open/Download links stopped activating for keyboard users — while the big
  look flashed open over the page. Activation targets (closest() over
  button/a/[role=button]/input/select/textarea) keep Space; bare page areas
  preview. Live: Space on the focused gear opens Settings; Space on a focused
  row opens the big look; Space on a focused link is the browser's own.
- a failed job poll killed the loop (unhandled rejection): the job line froze
  at its last state and "indexing…" stayed in the footer forever. Polling now
  answers "job poll failed: …" honestly, clears the indexing state, and stops;
  repeated busy-clicks poll one loop (state.jobPoll), not N.
- FastAPI's list-shaped validation detail printed "[object Object]" on the
  footer; api() renders each {loc, msg} and falls back to statusText for a
  non-JSON body.
- every settings-close path shares closeSettings(): aria-expanded always
  matches the panel's hidden state, opening focuses the first control, closing
  returns focus to the gear. Estimate's panel has a generation token, so a
  slow crawl's late reply can't write into a panel closed and reopened between.
- roving tabindex (selected 0, rest -1: 40-100 rows no longer turn every
  search into a tab storm), aria-selected on rows, the weaker fold says
  role=option + aria-expanded/aria-selected, group titles are role=presentation.
- Space in the field on the weaker row toggles the fold (as the document
  handler does), the code hint shows for the code-mode empty, and the query
  field carries maxlength=512 matching the API's cap.
- CSS: the ticked kind chips mix at 10% (the dark audio pair measured 4.3:1 at
  14%, 4.54:1 now — AA), .badge.warn wins the cascade it was losing to .badge,
  the audio control is sized where app.js puts it (.audio-picture), and the
  keys line unwraps under 720px. A data: favicon silences the browser's
  standing 404 in the console (no external URL: the pin tests stay green).
- the test image carries tini as its ENTRYPOINT. The suite's app spawns
  search-service as a child of the test process, and ParentWatch ends a
  helper whose parent is PID 1: run the image bare and pytest IS PID 1, so
  every helper died at once (`exit 0`, no stderr — 59 setup errors, measured).
  With tini as PID 1 the test process is a live parent, the production shape,
  and CI needs no --init.
- HEALTHCHECK is server/docker/healthcheck.py: 200 healthy, 503 ALIVE (the
  API answers 503 while the searcher respawns — Docker must not restart a
  live server over the engine's own refusal; urlopen raises HTTPError on 503
  so it is caught), everything else exit 1. The build asserts the script
  fails with nothing listening; CI polls the live server with the same file.
- measured honesty in the Dockerfile: base is Debian 13 (trixie), glibc
  2.41-12+deb13u4 (newer than the payload's 2.35 build glibc — forward, never
  back); no /data/locks (the writer lock is <index>/.digup-server.writer.lock);
  the inert DIGUP_SERVER_HOST/PORT ENV lines are gone (uvicorn's CMD args win
  in a container; those envs feed the bare-metal `python -m` entry); `digup
  --help` claims only that the CLI runs headless — §3's full acceptance is
  CI's boot step and the proof battery, against a live index.
- CI: 180-minute ceiling; the engine chain (ffmpeg, llama.cpp) is built
  host-side with the repo-pinned actions/cache and the same hash keys ci.yml
  uses, then seeded into the builder's cache mount through .ci-vendor/ (the
  vendor scripts land on their own stamps and skip the rebuild; hosted
  runners start their BuildKit mounts cold, and a fresh 15-minute ffmpeg
  compile per PR was overrunning the old ceiling). The drift guard runs FIRST
  and pins ModelFiles.revision + both file names + both sha256s. Zero-skip
  gate (any SKIPPED line is red; the clip must exist). Compose schema check.
  The boot step polls /health with the image's own healthcheck.py, asserts
  exit != 137 + 'Application shutdown complete', and uploads proof/suite logs.
- proof.sh counts failures: it piped every gate through tee and exited 0
  unconditionally, so a run where the index died and every search answered
  nothing was 'green'. Gates now record their labels, the pipeline's status is
  real (pipefail), and the eval ALL row must clear R@1 >= 0.60 / R@5 >= 0.85
  (the recorded 0.71/0.88 clear it with margin) before it prints PASSED.
- compose: restart: unless-stopped, and DIGUP_PUBLISH_PORT for the host side
  when 8787 is taken. localhost-only publish unchanged.
- .dockerignore excludes the corpus videos/clips, corpus2 media (45 MB rode
  into the build context) and .playwright-mcp/; .gitignore ignores
  .ci-vendor/* with a !-exception for the tracked .gitkeep the COPY needs.
- server/README.md: the suite's real number (75 passed / 1 deselected /
  zero skipped, buckets 17+19+6+13+17+4 collected 76) where two
  stale counts said 48 and 57; the tini rationale for the test image (the
  ParentWatch PID-1 trap, measured 59 errors); the -m slow clarification;
  the quick start's mkdir + chown -R 990 for real Linux hosts (Docker
  creates bind dirs root-owned, the server crashes EACCES on its lock);
  restart/publish-port notes; the corpus2 scale note (zGate 2.0 is the
  engine's gate on every platform - 1-3 hits on 61 files is the corpus,
  not a filter).
- IMPLEMENTATION_STATUS: Stage-11 section, newest-first - suite23/24, the
  parity identity table (adapter on a sqlite3 .backup snapshot == HTTP,
  identical hits/strong/z on five queries + keyword-first), the proof
  battery's 11 gates, the browser verification of the keyboard fixes, the
  CI/image facts, the commit shape, and the honest open items (CI first
  green pending, Stages 9-10).
- docs/implementation/review-round.md: the full finding -> fix -> test
  table for backend, UI, and build/CI, plus the deliberate non-fixes with
  reasons (no auth on a localhost-only server; no pixels cap; no fsync
  per job-log line; test image runs root; the engine's own limits left
  alone by the PR's rule).
- docs/implementation/stage-11-review-round.md: the stage record - the
  PID-1 discovery, the parity proof with the probe lesson (a read-only
  mount of a hot WAL index is not a snapshot: 0 hits, probe artifact),
  the corpus-scale quantification, the battery gates, the browser checks.
- Docs honesty pass, measured not asserted: base image is Debian 13
  trixie glibc 2.41-12+deb13u4 (newer than the payload's 2.35 build
  glibc, forward never back), no /data/locks (the writer lock is
  <index>/.digup-server.writer.lock), the shipped endpoint list
  (/preview|/text|/file, no settings endpoints), the timestamp-suffixed
  rotation shape + the 5 new job tests (13 total), the CORS/localhost
  wording, the media-surface notes.
- Host identity out of the stage records (this is a publication): the
  three host home paths become ~/Projects paths (a clone works from
  anywhere), and the exact OS/Xcode build codes (fingerprint details)
  come out. All build/proof commands in the docs are relative or
  env-var based: $PWD, $HOME, $RUNNER_TEMP, container paths.
@Ocean572 Ocean572 changed the title Server: self-hosted Dockerized media search API + web UI over the engine's published CLI (additive-only) Add self-hosted Docker server with media search API and web UI Nov 4, 2026
…1070

cuda-libs assembles the shipped chain by glob (cp -a keeps symlink+target; 5 lib
families + libnccl.so.2, 955 M) — the dotted -dev names are not in a runtime base;
the Dockerfile rm -fs Vendor/llama-build/CMakeCache.txt before build-llama: a flavor
switch on a shared cache mount must re-configure (stale GGML_CUDA=ON killed it,
measured 2026-11-04); gpu-handoff.sh takes CUDA_ARCHS as a build-arg (plain numbers
only — apt cmake 3.22.1 rejects 6.1 and native alike), checks the payload in /out,
waits /health then retries POST, and samples nvidia-smi at 0.15 s across a real index
job (PASS peak > 20% or busy-duty >= 10% with the model in card VRAM; a 2 s sampler
saw 0-12% on a provably-GPU job); the nvidia compose override carries target
server-runtime-cuda + the matching devel/runtime CUDA bases + CUDA_ARCHS so
--profile nvidia up -d --build builds the CUDA image on the box that has the card.
… success (6d97cd5); CUDA fix confirmed live by run ARahim3#7's 1 h 28 m 57 s green build-push
… 131,972 files / 131,758 searchable / 403,831 vectors / 1.39 GB

Job 3 record (engine's own): started 01:32:33 → ended 07:13:03 box-local, 20,225.0 s,
files_done 7,097, files_failed 1, errors 0. Whole library across the three kept records:
131,543 files completed, 421 recorded failures (video decode refusals), 0 server-side
errors; engine working time 59,940 s = 16 h 39 min, 2.195 files/s average — 33.75% of
the estimate forecast's 177,575.3 s (GPU-assisted). Three verified ceiling-stop/resume
cycles. Sampler peaks: 99% GPU (job 2) / 90% (job 3), model resident 1,780–1,802 MiB;
container 207.75% CPU, 3.436 GiB RAM. Final timed searches 0.6875–0.8004 s, five
queries, all 10 hits/10 strong/meaning true. Index 1,387,327,274 bytes; / free 7.0 G
at end. Full sampler table + arithmetic in server/data/bench-18m-stats.md (git-ignored).
…rge shapes pinned in CI, ghost-row recreation lesson measured and documented

docker-compose.lan.yml appends a LAN host binding for the API (env-forced
DIGUP_LAN_BIND — refuses to apply without it; the base file's 127.0.0.1 mapping
stays: compose merges ports lists). Header states the trade plainly: the server has
no auth, so a LAN bind exposes the whole indexed library to the network; home-LAN
use only, the §7 shape for anything else is an auth'd proxy. Field-proven on this
box merged with the GPU override: healthy, UI + /api/v1 + searches on the host's
LAN address, 10 hits/10 strong in 0.71-0.81 s at the 132k-file index. CI now
schema-checks seven merge shapes (adds base+lan, lan+nvidia, ghcr+lan; uses RFC
5737 TEST-NET addresses — documentation addresses, nobody's). The server/README.md
LAN section documents the shape and pins the recreation rule the field run proved
the hard way: recreations must pass the SAME file chain AND env every time —
dropped env moved the media bind one folder off the roots the index rows carry,
every row fell out of scope, and the API answered 0 hits (keyword queries included)
while health/files/vectors/job-log stayed normal; same command with the original
env restored 10/10 strong, no prune, no reindex, the three job records untouched.
…58 s (7 merge shapes), publish 1 h 21 m 44 s, both tags republished
…st, principal log, embed widget, the Nextcloud shared-stack compose shape

The host-application future (docs/implementation/stage-12-integrations.md, the binding
contract with §11.x numbers quoted in code): one compose stack shares the Nextcloud data
dir with the DigUp server read-only, and a Nextcloud PHP app (its own repository,
digup-nextcloud) queries the API server-to-server with a read-scope token. Per-user ACL
is the host app's boundary (its permission APIs filter every result set); the server
gains no user concept and touches nothing.

security.py: DIGUP_SERVER_READ_TOKENS=name:token,… — every entry guards the read scope
exactly like DIGUP_SERVER_TOKEN (index/estimate stay admin-only), first value wins per
name, malformed entries skipped with a named warning; Auth.token_names() answers scopes
per token (admin covers the read side); BearerAuth records the accepted principal on the
scope; PrincipalLog (outermost) writes 'POST /api/v1/search -> 200 principal=nc-a' for
every guarded request — health/docs excluded, unguarded deployments log principal=none.
Cors middleware: DIGUP_SERVER_ALLOWED_ORIGINS exact origins, inert by default, preflight
204 grants, decorated answers, and it sets the guard's cross-origin-POST exception ONLY
for allowlisted origins (Host allowlist still outranks everything; the cookie-free CSRF
argument spelled out). api.py installs all four middlewares always: inert knobs, same
opt-in discipline as every §7 feature.

server/ui/embed.html/.css/.js — the compact widget for host apps: no external URLs, no
build step, server-issued media handles, engine fields verbatim, keyword-first typing
with embed_ahead, IntersectionObserver previews, theme via postMessage from the host; the
token enters via ?token=, moves to sessionStorage, history.replaceState strips it from
the URL; previews are authorized blob fetches, never a token in an <img> URL.

deploy/docker-compose.nextcloud.yml — the shared-stack example: data dir mounted
:ro at /media/photos, group_add default 33 (the official image writes the data dir as
www-data), appdata_<instanceid> exclusion COMPOSED from DIGUP_NEXTCLOUD_INSTANCE_ID
(occ config:system:get instanceid — CACHEDIR-tagged preview/thumbnails/versions auto-skip
by the engine's own rule on top), token map entry nextcloud:<secret> + admin token
env-forced with plain messages, allowed hosts server,127.0.0.1,localhost, Nextcloud
headless-install env, config/custom_apps volumes, the SSRF note. :? forcing verified:
each missing knob names itself (measured exit 1). CI schema-checks grow 7→9 shapes
(standalone + LAN-merged, RFC 5737 + synthetic paths).

Contract suite: 14 new tests (map scopes/revocation/attribution, CORS inert/allowlisted/
composed-stack, embed surface pins, middleware install pins, end-to-end token map over
the real API: search 200 with map tokens, index/estimate 403 for them, admin estimate
200, tokenless 401). Full suite in the container, all four mounts: 116 passed, 1
deselected, ZERO skips, 150.05 s — every pre-existing pin of the bearer/host rules
passed unchanged over the refactor.

Docs: stage-12-integrations.md (the contract, gates, multi-instance + remote-GPU
honesty incl. the NFS prerequisite and 1-search-at-a-time concurrency), docs README
entry, server/README Nextcloud section, API.md token-map rows + CORS section,
.env.example knobs. Next: the pilot on this box (Nextcloud + server one stack,
corpus2 public gallery as files, planted junk dirs, token/ACL/no-mutation gates),
then the digup-nextcloud app repository.
…dit log gets a handler, 28 pilot gate numbers

Two fixes measured on the Nextcloud 28 field pilot (gate lines in
IMPLEMENTATION_STATUS.md): the estimate argv now carries the excludes the
index job will honour — the forecast describes the tree the indexer walks
(new suite test pins the argv shape); the principal audit logger carries
its own stdout handler (uvicorn's config leaves application loggers
handlerless, so the lines never appeared). Suite: 117 passed, 1 deselected,
0 skipped, 111.09 s.
…2 + FUSE + live propagation), per-user multi-root env, host-mount script, CI schema lines, pilot gates measured
…ng, trusted-domain rule measured (first-install-only env application), CI schema line
Reference an existing data tree without moving it: a read-only /extstorage
bind on the Nextcloud service (the files_external local backend's datadir
points inside it; the mount is read-only in Nextcloud's config, and the
DigUp server binds the SAME host folder). group_add carries the tree's
host group so uid 33 can read it (DIGUP_NEXTCLOUD_EXTSTORAGE_GID, default
1000). CI covers the new env in the compose config check.
…group

Apache workers run as www-data and call initgroups() for the configured
User, so the web process reads a group-restricted host tree only when
www-data holds that group in the container's /etc/group (group_add alone
cannot reach them: setuid drops supplementary groups). The official image
only runs executable hook scripts, hence the committed mode 755.
…search get

Measured 2026-11-08 on the Nextcloud pilot: index_argv omitted --exclude/
--exclude-ext while estimate_argv had them (§11.3). The job walked the
tree's appdata folder — 1,470,439 preview files — against a 131,972-file
forecast: 250 h of embedding nobody asked for, previews in results, and an
estimate that timed out walking work the excludes had already cancelled.
The engine's shared parser accepts both flags on index-service (verified:
left_out reports 'folders excluded folder'). The test's title already
promised 'the excludes the job will honour'; the suite asserts it for
index_argv now.
UX ask measured on the Nextcloud pilot 2026-11-08: a blocking window.prompt
fires per new tab, cannot be reopened, and is gone once dismissed. The main
UI's 401 now points at the gear (a soft pulse) and says where to go:
Settings -> Access, where the key is pasted, saved (localStorage), cleared.
The key is sent as a bearer header only, never in a URL; a 401 with a
stored key shows the server's own words, so a wrong key cannot loop the page.
Per-client tokens for other applications stay env-issued
(DIGUP_SERVER_READ_TOKENS) and are never answered over the API — the field
is paste-from-deployment. The embed widget, which has no settings panel of
its own, keeps its prompt (test_integrations asserts it). The suite asserts
the new main-UI shape (field present, no window.prompt in app.js).
…gine picks GPU

All measured: job-excludes gap (1,643,492 walk vs 131,973 forecast), recovery via
graceful stop + interrupted record (<config>/jobs.jsonl survives index wipes by
design), fresh walk 131,973, CUDA image built on the box from the current tree
(the ghcr CUDA image predates the excludes fix), speed --model device gpu +
agreement 0.99977, utilization gate PASS (duty 45.9%, peak 95%, 1,697 MiB VRAM),
1.93 files/s → 18.5 h forecast. Suite 117/1/0 twice.
…alone AND serving applications

The compose server keeps running standalone over its own media index, and serves
other applications: it MINTS their access keys and builds a SEPARATE index per
application, never the media one. The engine changes nothing — it answers for any
(roots, --index) pair; the server supervises each pair independently.

- targets.py: registry (DIGUP_SERVER_APP_TARGETS entries name=roots:index, roots
  colon-separated with the index LAST, merged with <config>/targets.json, env wins
  per name) with strict validation: slug names, reserved 'media', absolute paths,
  and a target's index may not BE the main index.
- api.py: every guarded endpoint addresses a target (?target=name, default media);
  main scope = the lifespan's (the injected-searcher seam the 503 contract pins,
  resolved at call time); app targets spawn LAZILY — own search-service child, own
  JobRunner (jobs-<name>.jsonl), own writer lock for free (the flock lives beside
  each index folder, so crawls of different indexes run in parallel). Media handles
  resolve their target by longest-root match: preview/text/file work across indexes.
  GET /api/v1/targets lists the registry — counts from a running target's live
  searcher (the authority on a store it has open: measured in-suite, a WAL pinned
  by the searcher's reads reads 0 to a fresh CLI open), from the CLI table for
  unspawned targets, null for an empty folder; no engine is spawned for a GET.
  The single status cache is keyed per index folder (no cross-target bleed).
- security.py: mint_tokens — server-issued app:<name> keys + admin key when the
  deployment has none, stored 0600 <config>/tokens.json (recreation-safe,
  restart-stable, displayable from any boot); minting fires ONLY when targets
  exist (a target-less standalone deployment runs unguarded, byte-identical to
  before); env secrets never stored, never echoed. The bearer gate binds tokens to
  targets: admin answers all; read/plain-named tokens answer the main index (their
  pre-stage-13 contract, unchanged); an app:<name> token defaults to its OWN target
  (plain paths — the bridge needs no target parameter) and is 403 on any other;
  mutating routes accept an app token for its own target only. The audit line names
  the target too (principal=app:nc target=nc). GET /api/v1/tokens shows minted
  values to the admin principal only.
- UI: gear → Settings → Access gained Application keys (copy buttons) and Indexes;
  both hide for non-admin keys. Docs: API.md 'Named indexes' + auth table,
  .env.example, deploy/docker-compose.app-targets.yml (opt-in -f; the target's
  index rides its own bind mount, recreation-safe like the media index's), CI
  validates the fragment with and without the nvidia profile.
- Dockerfile: ARG SWIFT_TESTS=1 gates the engine swift test in the builder stage
  (CI never passes the arg, gate unchanged; local suite builds use 0 after the
  measured box fact — CodeTests gitListingNeverRunsRepositoryFsmonitor dies in
  this box's builder; the contract suite is the server's gate).

Suite (this box, container, four mounts, -rs, zero skips): run 3 6F/123P →
4 2F/127P → 5 1F/128P (the preview poll's own text-doc pick: the engine's render
refuses text docs permanently) → 7 + 8: 129P/1D/0F → final tree: 129 passed /
1 deselected / 0 failed / 0 skipped in 160.04 s.

Live demo gate (compose stack, port 8792, CPU, public corpus photos): minted admin
+ app keys 0600; app token answers target nextcloud roots /media/app, 403 on
?target=media; its index 202, second 409 naming the live job, job done, 36 files /
36 searchable; media index untouched (0 files, 0 hits); app search 3 hits all under
/media/app; targets counts media 0 / nextcloud 36; tokens visible to admin only,
403 to the app key. The Nextcloud bridge 0.4.0 (status line + Index now) is
deployed on the pilot and gated: status 200 target media 131,973 files / 118,778
searchable live, index-now passthrough verbatim, non-admin 403.
…ronicle

Suite runs 3→11 with each failure named and diagnosed (binding default, injected-
searcher seam, tokens-file dir, env main-index clash, status-cache bleed, restart
displayability, admin principal form, parser-shape crash — the shipped docs/compose
shape won, parser moved to it — and the WAL-pinned store the CLI open reads empty:
/targets counts now come from a running target's live searcher), the SWIFT_TESTS
builder fact, the live demo gate numbers, and the refreshed 'What runs today'.
…ild path reaches the Dockerfile's engine-test gate; unset keeps it running (CI shape unchanged), a box with a measured builder-environment failure documents 0
…s, inert minting, contract unchanged) + the pilot job's 10 h ceiling → interrupted 118,206 → 202 resume + battery numbers
…ey (2026-11-08 UI brief)

The main page keeps no status nag: the "status unavailable: … paste your key" line
is deleted, and the engine's counts live in the panel's `The index` (per-target rows
with files/searchable/roots). Access/Application-keys/Indexes collapse into two
sections in the operator's reading order: `The index` and **`API key`** — the minted
keys as plain rows (`API key`, `API key — <target>`) each with **Copy**, a
**Regenerate** button behind a confirm ("the applications that have these keys lose
access"), and the small paste field for deployments whose keys come from the
environment (env keys are never shown and never rotated — their operator gives THIS
browser its key from the deployment's own config).

`POST /api/v1/tokens` (admin scope) is the rotation: every minted value (admin + app
keys) gets a fresh `secrets.token_urlsafe(24)`, persisted 0600 in the keys file, live
immediately — the old values answer 401 from that call on, which is exactly what the
confirm warns about; the browser's own key updates itself. Environment-configured keys
are kept untouched (a rotation must not silently override the deployment's own
config), and a deployment with nothing minted answers 400 honestly. `Auth` stays
frozen: the rotation swaps the bearer gate's auth reference by walking the built
middleware stack. The admin-scope check also accepts a direct admin-bearer match — an
admin-token-only deployment deliberately leaves reads open (principal `none`), and
this surface exists to answer the admin key's owner.

Measured on this box (ai-ubuntu-computer): suite run 13 — 133 passed / 1 deselected /
0 failed / 0 skipped, 224.61 s (three new rotation contracts, reworked UI contract);
demo flow gate green; rotate gate live: POST 200 rotated, old app key 401 and old
admin key 401 on the same call, new app key 200 target `nextcloud`, 403 cross-target,
tokens file 0600 carrying exactly the new values, media untouched, search after
rotate 3 hits all under the app's own roots; served UI markup: sections present,
main-page statusline absent.
…24.61 s), demo flow + rotate gates green, the pilot's app at 0.4.1 (one "API key" field)

The paragraph tells the rework as measured: the rotation's live behavior on the demo
stack (old keys 401 on the same call, new keys answering their own target, keys file
0600 carrying exactly the new values, media untouched), the served-UI markup check,
and the "What runs today, exactly" table now carries the current stacks: the bench
CUDA image awaiting the UI-rework rebuild, the pilot's media-resume job in flight
with app 0.4.1, the demo stack with both gates green.
…the gear's API key section is Copy + Regenerate (2026-11-08 user brief, round 2)

The user's model, made the contract: "the standalone app just uses the directory that
was set in the environment" — main page searches with NO key. The gate now draws the
line where the deployment's own typed secrets end: keys SET IN THE ENVIRONMENT guard
every call (the existing shape, its contracts untouched — test_security pins it); keys
MINTED by the server (applications registered) guard what keys are FOR — index jobs,
estimates, the keys surface, and each application's OWN index (reads, previews, its
own crawl) — while the main index's reads answer openly to the operator's browser:
status, search, targets, embed_ahead. Media handles close the side door: a handle
whose file lives under an application's roots answers 403 to a key-less request and
200 to that application's key. `POST /index`, `POST /estimate`, `GET /tokens` keep
their key requirements exactly.

UI per the same brief: the gear's API key section is ONLY the minted-key rows (label,
value, Copy) and Regenerate — the paste field, Save, and Clear are deleted (the key is
server-generated at runtime; the APPLICATIONS paste it into their settings; Regenerate
adopts the browser's new admin key itself). Rows became a uniform grid — label | value
| action, one row height, equal padding and borders, key text clips with an ellipsis
and Copy carries the whole value. The 401 message names the server's shape honestly
(a minted server lists its keys in the gear; a tokenless server asks for nothing).

Measured on this box: suite run 14 — 135 passed / 1 deselected / 0 failed / 0 skipped,
164.25 s (two new shape contracts: open main reads + guarded mutations/keys/app target;
open principal never previews an application's files); demo flow gate on the rebuilt
stack: status no key 200 target media, index/estimate no key 401, tokens no key 403,
app-target read no key 401, app key 200 own target + 403 cross, 202→409→done, 36/36,
media untouched; rotate gate: rotated 200, old keys 401, new keys answer, file 0600,
media untouched, search after rotate all under the app's roots. Bench :8787 (tokenless
launch): wakeboarding un-keyed 10 hits 10 strong 0.72 s; 10/10 queries strong
0.73–0.92 s; index intact 131,972/131,758; served UI carries the grid rows, no paste
field.
…ar API key = Copy + Regenerate, the app's preview-pane visibility fix; suite run 14 (135/1D/0F/0S, 164.25 s), demo gates 4 + rotate 2, bench tokenless numbers
…s the shape honestly; Estimate spins a wheel in the gear (2026-11-08 user brief, rounds 3–4)

The user's live test of the panel: uneven rows, a lone Regenerate button where keys
should render, and an estimate popup on the main page they never asked for.

Every panel control is now exactly 2rem tall — appearance select, Index now, the prune
row, Estimate, Regenerate, every key and index row — section titles aligned to the
first row; key rows are `label | value | Copy` in the grid, key text ellipsis-clipped
(Copy carries the whole value). The API-key section reports the shape truthfully:
minted keys as rows; an environment-keyed deployment as the honest row "Set in the
server's environment" (typed keys are never echoed — the panel says so); a tokenless
deployment has NO keys and the section collapses entirely (minting fires when an
application registers — there is nothing to show, so nothing shows). Estimate: a CSS
spinning wheel beside the gear's button is the progress indicator while the engine
walks; the main-page "estimating — the engine is walking the folders…" line is
deleted; the forecast fills the panel on arrival, the failure line stays.

Measured on this box: suite runs 15 and 16 — 135 passed / 1 deselected / 0 failed /
0 skipped (205.85 s; 164.51 s), module run 37 passed mid-round; served UI on both
stacks (:8787 tokenless, :8792 minted): api-key-sec present, estimate-spin present,
paste field absent, grid rows + 2rem rule + spin keyframe live; wakeboarding un-keyed
10 hits 10 strong.
…e; gear shows the key keylessly (stages 14 + round 6)

- DIGUP_SERVER_MINT_KEYS=1: the STANDALONE box mints its admin key at boot with no
  application registered — mint_tokens(mint_admin); compose passes the knob.
- POST /api/v1/targets (admin key): an application REGISTERS at the RUNNING server —
  startup validator at runtime (slug, absolute paths, not-the-main-index, media
  reserved, 409 dupes), server mints its PRIVATE key (returned once, persisted 0600),
  own index folder (default <config>/index-<name>), targets.json persists it; its key
  answers only its target (own crawl in the background, invisible to the main page).
- GET /api/v1/tokens is key-free in a SERVER-MINTED deployment: the gear renders
  'API key | value | Copy' with no key in the browser — the ticket model (main page
  searches keyless; the gear's Copy row is what applications paste). Typed secrets
  (env shape) stay guarded, never displayed (keyless 403 at the endpoint, app keys
  403); security.py: env_secrets() module fn, _env_secrets delegates.
- UI round 6: Regenerate rides the key row's right edge (grid label|key|Copy|Regen,
  built in app.js); Estimate's forecast sits beside the button (plain row column —
  no stray box); a finished crawl re-reads the index list (no stale target rows).
- Contracts: minted keyless tokens 200-with-value, env-shape keyless 403, app-key
  403 the surface, registration flow, UI pins (regen inline, grid4, job-finish reload).
Measured (suite run 18): 137 passed / 1 deselected / 0 failed / 0 skipped, 197.37 s;
live :8787: keyless tokens shows the minted value (= the 0600 file), wakeboarding
un-keyed 10/10 strong, index/estimate un-keyed 401, app-connect flow verified (app
row keyless-shown, app key 403 surface, own target 200), wiped to pristine standalone.
…no scroller, real Estimate switch

The API-key row was '9rem minmax(0,1fr) auto auto': label + gaps + two padded buttons
≈ 394 px minimum inside a 271 px control column (25 rem panel minus the label column),
so the row sat ~120 px past the panel's right edge, worse with the overflow-y:auto
scrollbar. Fixed at the grid level:
- .appkey-line: minmax(6rem,9rem) | minmax(0,1fr) | min-content min-content, with
  min-width: 0 on every track; #settings .set-sec > * { min-width: 0 } — a row can
  never exceed the column it lives in (label + key ellipsize, buttons keep their word).
- Panel: min(27rem, 100vw - 2rem), overflow-x: clip, NO scroller (overflow-y:auto made
  a clipping box that cut controls, stole ~10 px, and let the modal drift past the
  viewport); sections fold to one column under 720 px (label above its controls).
- Appearance select fills its control column (its native popup opens in the top layer,
  never clipped); the two-column label/control grid is preserved and pinned by measure.
- Estimate is a real toggle switch (native checkbox + track/knob, 2.6rem x 1.5rem,
  2rem row system) gating the Estimate button, its forecast rows stacked directly
  above the control line (#estimate-ctl) — no dead space, no popup on the main page;
  a finished crawl re-reads the index list (no stale target rows).
- The gear ADOPTS the minted key it is shown (its own controls are admin-scope): Index
  now / Estimate / Regenerate work from the panel with zero typing; a stored key the
  server no longer recognizes is replaced by the value shown now. Main-page reads stay
  key-free; an env-keyed deployment shows nothing and adopts nothing.
- Restored <p id='engine-says'> — my round-6 line deletion removed it and renderList()
  threw at first paint.
Measured: suite run 19b — 137 passed / 1 deselected / 0 failed / 0 skipped, 189.48 s;
module runs 50 green; geometry gate 34 PASS (minted flow on the :8793 testbed: adoption,
toggle gating, estimate rows, Copy, Regenerate + old-key 401, stale-key recovery);
round-7 layout gate 42 PASS at 1280/900/640; live :8787 27 PASS (panel right 1196/1280,
key row 1182.2 inside it, control column aligned in all five sections, select fills the
column, toggle 41.6x24 px, no inner scrollers, no page horizontal scroll, keyless search
10 hits 10 strong, index/estimate un-keyed 401).
The vanilla UI becomes a Lit + Vite app (server/ui/src/, stable asset names
app.js/embed.js/style.css/embed.css; ES modules, CSS custom properties; no
TypeScript, no framework/router/state lib, no Node at runtime; the server
speaks its published interfaces unchanged).

- Settings gear: four sections with icons, cards, semantic badges; the roots
  line and the estimates description removed; Estimate rides beside Index now.
- Header full-bleed (title left, gear right), centered search axis, footer
  instructions at the right edge.
- Kind row: the all button is a TOGGLE — all six on, click clears all, click
  restores all; aria-pressed marks the state. Engine semantics verbatim.
- Copy buttons work over plain http (async clipboard when present, hidden
  textarea execCommand fallback).
- Cleanup audit (post rounds 4-7): zero dead CSS selectors, zero unused
  exports; removed the roots slice write, the estimate-toggle listener, and
  the rule-less set-lines class. Contract-pinned remnants kept with reasons.
- Contract suite + CI + compose + docs follow the new markup (137 tests).
- Dev server (vite.config.js) is env-forced: loopback by default, LAN bind
  and host allowlist opt-in.

Measured on ai-ubuntu-computer (x86_64, Ryzen 9 7950X): vitest 54/54;
vite build 287 ms (app.js 65.53 kB / 20.36 gzip); contract suite 137 passed,
1 deselected, 0 skipped, 172.19 s on the cleaned tree. Nextcloud app 0.4.3
(media-type chips + live index counts) gate-verified on the pilot stack.
…ged shape

- docker-compose.all-in-one.yml: the shipped chain merged into ONE file —
  Nextcloud + server (CUDA build target) + LAN binds on both web ports + a
  named app index target + the --profile dav mount container. Env names
  unchanged; the header prints the launch chain and how to peel every layer
  (CPU-only, loopback-only, data vs WebDAV shape, no app target).
- deploy/README.md: the operator page — compose file map, every env var with
  its effect, launch chains (example values only), the measured rules
  (chown 990, ghost-row prune, single uvicorn process, 503-warming honesty,
  409 one-writer lock, 143/137 stop semantics, GPU-is-measured honesty), and
  the API surface table with scopes, pointing at server/docs/API.md as the
  canonical contract.
- server.yml: the compose-schema step validates the all-in-one chain with the
  full synthetic env chain (15th schema shape; RFC 5737 addresses, /srv paths).

Measured on this box (x86_64, Ryzen 9 7950X): host schema checks full /
no-profile / dav-only all OK; resolved config shows the CUDA target, the
nvidia reservation, app=/media/app:/data/index-app, four port mappings, the
ro + rslave binds; the exact CI line ran clean on the host. No server/ui code
changes — the contract suite is untouched by this commit (docs + example
compose + a CI validation line).
Full-surface audit (Python 2,776 L, UI 2,447 L, Dockerfile, compose, CI):
the implementation was disciplined — zero unused imports/defs, zero dead CSS,
all stages/vars referenced, no engine duplication. Applied changes:

- engine: drop IndexService.sync_code (zero refs) and SearchService.folders()
  + _folders_fields respawn replay (the server's scope is argv at spawn)
- jobs: counters ride the live job record only (_live_counts) — the per-event
  events list + _counts re-scan were a second source of the same numbers; a
  1.8M-file job no longer buffers every event twice. Test pin adapted to the
  real paths, same assertions
- api: make_runner — the JobRunner knobs (ceiling, rotation, log name,
  reload→prewarm) existed twice verbatim; now one source
- CI: the byte-identical vendor-seeding step in both jobs is one YAML anchor
  (PyYAML-verified, run scripts md5-identical)
- ui: 11 internal-only export keywords demoted (self-registering components,
  media.js helpers — zero importers)
- docs: DIGUP_SERVER_STATUS_TTL_S documented in .env.example + deploy README

Gates: py_compile OK; UI units 54/54 (host + in-image); vite build clean;
contract suite on the final tree: 137 passed, 1 deselected, 0 skipped,
173.56 s. Architecture untouched: passthrough, engine source of truth.
Ocean572 added a commit to Ocean572/DigUp that referenced this pull request Nov 10, 2026
…r — FastAPI adapter, Lit+Vite UI, Docker/compose, contract suite, ghcr images

68 commits, additive-only: server/**, deploy/**, docs/implementation/**, .github/workflows/server.yml, .dockerignore, .gitignore appends.
Engine untouched (Sources/**, Package.swift, scripts/**, shell/**, website/**, worker/**, evals/**, Tests/**).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proof of Concept: Self-Hosted Docker Server for DigUp Semantic Media Search (Nextcloud Integration Plans)

1 participant