diff --git a/.env.example b/.env.example index 4ed6acb1..b16fc389 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,18 @@ CIX_PORT=21847 # from this machine — the right choice for a desktop install, where exposing a # code index to the whole LAN is rarely intended. Bare address, no port. # CIX_BIND_ADDR=127.0.0.1 +# Legacy chromem-go store: read once on startup for the one-time import into +# the SQLite vector store, then left untouched as the rollback path. CIX_CHROMA_PERSIST_DIR=~/.cix/data/chroma +# Vector store: one SQLite database per embedding namespace. Defaults to a +# sibling of CIX_CHROMA_PERSIST_DIR (~/.cix/data/vectors), so a deployment that +# only overrides the chroma dir still lands its vectors on the same volume. +# CIX_VECTORS_DIR=~/.cix/data/vectors +# PRAGMA mmap_size for the vector store, in bytes. 0 (the default) is off. +# Roughly 40% lower search latency in exchange for resident memory — every +# connection maps the database and mapped pages count in RSS. Do not set it +# under a tight memory limit. Example: 2 GiB. +# CIX_VECTOR_MMAP_SIZE=2147483648 CIX_SQLITE_PATH=~/.cix/data/sqlite/projects.db CIX_GGUF_CACHE_DIR=~/.cix/data/models # Base dir for cloned GitHub repos (each clone lives at /repos//). diff --git a/.gitignore b/.gitignore index 58efa0e7..aebda482 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Environment .env .env.local +# Timestamped backups made before editing .env — same secrets, one rename away +# from being staged by a `git add -A`. +.env.bak* # Python __pycache__/ diff --git a/cli/go.mod b/cli/go.mod index effec5c1..862fb955 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,6 +1,6 @@ module github.com/dvcdsys/code-index/cli -go 1.25.12 +go 1.25.13 require ( github.com/charmbracelet/bubbles v1.0.0 diff --git a/doc/CONFIG_REFERENCE.md b/doc/CONFIG_REFERENCE.md index 4a4eae58..1e2efc13 100644 --- a/doc/CONFIG_REFERENCE.md +++ b/doc/CONFIG_REFERENCE.md @@ -33,7 +33,9 @@ the DB. | `CIX_PORT` | `21847` | Listen port (both Docker images bake this in). | | `CIX_BIND_ADDR` | — | Interface to listen on, as a bare address with no port. Empty means every interface, which is what a container needs; set `127.0.0.1` to make the server reachable only from the machine it runs on. The macOS app writes `127.0.0.1` at first run and exposes a menu toggle. | | `CIX_SQLITE_PATH` | `/data/sqlite/projects.db` | SQLite path. Suffixed with the model-safe name on open. | -| `CIX_CHROMA_PERSIST_DIR` | `/data/chroma` | Vector store directory. | +| `CIX_CHROMA_PERSIST_DIR` | `/data/chroma` | Legacy chromem-go store. Read on startup for the one-time import into the SQLite vector store, then left untouched as the rollback path. See [VECTORSTORE.md](VECTORSTORE.md). | +| `CIX_VECTORS_DIR` | sibling of `CIX_CHROMA_PERSIST_DIR` (`/data/vectors`) | Vector store directory: one SQLite database per embedding namespace. | +| `CIX_VECTOR_MMAP_SIZE` | `0` (off) | `PRAGMA mmap_size` for the vector store, in bytes. Roughly 40% lower search latency in exchange for resident memory — mapped database pages count in RSS. | | `CIX_GGUF_CACHE_DIR` | `/data/models` | Where downloaded GGUF files live. | | `CIX_PUBLIC_URL` | — | Externally-reachable URL used to build GitHub webhook delivery URLs. Empty disables webhook URL display. | diff --git a/doc/DATABASE_MAINTENANCE.md b/doc/DATABASE_MAINTENANCE.md new file mode 100644 index 00000000..82cd66e1 --- /dev/null +++ b/doc/DATABASE_MAINTENANCE.md @@ -0,0 +1,268 @@ +# Database maintenance + +SQLite does not shrink a file when rows are deleted. The pages go on a freelist +and are reused by later writes, so a database that has lost more data than it +has since gained stays large and mostly empty. The instance that prompted this +feature was **8.86 GB with 48% of the file on the freelist** — 4.3 GB of +nothing. + +Server → Resources → Database reports that and offers two ways to act on it. + +## The two actions + +| | What it does | Cost | +|---|---|---| +| **Reclaim now** | Returns free pages to the filesystem in bounded chunks | Milliseconds per chunk, no window, no restart | +| **Compact now** | Rebuilds the database into a fresh file and replaces it | A read-only window, then a restart | + +Reclaim folds the write-ahead log back into the database file as part of its +work, because in WAL mode the file does not actually shrink until that has +happened. There is no separate control for it: SQLite checkpoints the log +automatically once it reaches 1000 pages — 4 MB, which is where it sits — so a +button offering to reclaim those 4 MB from a multi-gigabyte database would be +duplicating the automatic behaviour in the ordinary case and unavailable in +the one case it would matter, since a log that has grown large is a log some +reader is holding open. + +Reclaim needs the database to be in **incremental** auto-vacuum mode. That is a +**setting**, not an action, and it lives on its own two-way switch. Compaction +never changes it: asking for space back and asking to change a setting are +different requests, and one must not quietly do the other. + +Moving the switch does cost a rebuild, in either direction, because rebuilding +the file is the only way SQLite can change the mode of a populated database. +Moving it to the position it is already in costs nothing and does nothing. + +Reclaim returns space but does not defragment. Compaction rebuilds the file and +improves read locality, so it stays useful — just rarely, rather than as the +only tool available. + +## What a compaction actually does to the server + +**For the copy — roughly a minute per 8 GB on a warm SSD — the server is +read-only, not down.** + +- Search, browsing and every read keep working. +- New logins and every change are refused with `503` and a `Retry-After`. +- Indexing and scheduled polling pause. The CLI watcher already retries and + re-indexes pending changes on recovery, so a refused write is a delay rather + than a loss. +- Existing sessions and API keys keep working. Their last-seen timestamps stop + being refreshed for the duration, which is invisible against a 14-day + session lifetime. + +**Then the server restarts itself** and is unavailable until it has finished +starting up. + +The restart is the mechanism, not a fallback. Thirteen long-lived services hold +the database handle and none of them can be repointed at a new file, so the +swap is performed at boot, before anything opens it. The process re-executes +itself rather than exiting, so a container keeps its PID 1 and no restart +policy or supervisor is involved. + +### Why writes have to stop + +Compaction is built on `VACUUM INTO`, which produces a **snapshot**: the copy's +contents are fixed at the moment its read transaction opens. Measured on a +clone of the real 8.9 GB database, 18 200 rows were written during a 76-second +copy and 850 of them reached the copy. Adopting such a copy would have silently +discarded the rest. + +The freeze is three layers deep: + +1. **A route gate** refuses the endpoints that write, in microseconds. It is + classified per route, never by HTTP method — search is a `POST` and has to + keep working. +2. **Background work is stopped and drained**, not merely asked to stop. +3. **The compactor holds a write transaction** for the duration, so anything + that slips past the first two is refused by SQLite itself. + +The gate leads because the lock alone would be a disaster: a refused write sits +in SQLite's busy handler for the full timeout — measured at 5.06 s — holding one +of eight pool connections, and eight of those stall reads too. + +## If the machine dies mid-operation + +Nothing is lost, and nothing needs to be resumed by hand. + +Progress is journalled to `maintenance.json` beside the database, written +atomically, with an append-only trail in `maintenance.log`. On the next start, +every combination of that journal and the files actually present on disk maps +to exactly one recovery action. An interrupted copy is discarded; an +interrupted swap is carried forward or rolled back. The original is deleted +last, so no interruption can leave the server without a database. + +A compacted copy is only adopted after it has been proved to be this database: +row counts taken from the source under the freeze are re-checked against the +copy, together with the header's own claim about the file's length. A copy that +fails either is discarded and the original is kept. + +Databases under 512 MB additionally get a `PRAGMA quick_check`. Larger ones do +not, and that is a measured decision rather than an omission: on a real 4.5 GB +copy the check did not finish inside a 30-second budget, and since it runs at +boot before the listener binds, it was buying thirty seconds of extra downtime +and then discarding its own result. + +The state lives in a file rather than a table because the database is the thing +being replaced: a row describing the operation could not be written during the +read-only window, could not survive the swap, and could not be read back during +the restart. + +## What a real run looked like + +Against a copy of a production database, 8.25 GB with 47% waste: + +``` +00:00 compaction requested 202, server goes read-only +00:00 reads 200 · writes 503 · health 200 throughout +01:35 copy complete, 4.4 GB, verified +01:35 server re-executes itself +02:07 listening again + 8.25 GB → 4.18 GB, 4.07 GB returned to the filesystem + 48 projects, 297 563 chunks, 2 users — unchanged +``` + +The read-only window was 95 seconds; full unavailability was the ~30 seconds +of restart after it. + + +## Running it on a schedule + +Both operations can run on a **crontab expression**, in the server's local +timezone, and each has its own on/off switch: + +| Task | Default | On by default | +|---|---|---| +| `db.reclaim` | `0 3 * * *` | only on a database already in incremental mode | +| `db.compact` | `0 4 * * 0` | never | + +An interval would have been the smaller change and the wrong one. "Every 24 +hours" is measured from the last run, so a single manual compaction at 18:00 +moves every subsequent nightly run to 18:00 and it drifts from there. cron is +anchored to the clock, which is what "every night at midnight" means. + +The schedule says *when to look*; the thresholds say *whether it is worth it*. +A due run still does nothing unless the waste is over **both** 25% and 256 MB — +a percentage alone nags on a small database where 40% of 12 MB is not worth the +work, an absolute figure alone nags on a large one where 500 MB of slack is +ordinary headroom. Indexing in flight also defers a run, including a CLI push, +which holds no row in the jobs table. + +Defaults depend on the database's own mode. A file created by a recent build +can reclaim incrementally, so nightly reclaim is on. A database carried over +from an older install cannot, and the only thing automation could do for it is +the expensive rebuild — so it stays off until an admin opts in. **An upgrade +never starts blocking anybody's server on its own.** + +### What crontab means here, exactly + +- **A missed slot is not queued up.** A run that overruns its own schedule + loses the slots it ran through rather than firing a burst afterwards. +- **A slot missed while the server was down** is skipped for `db.compact` — + noticing at 09:00 would mean a read-only window in the middle of the working + day — and caught up for `db.reclaim`, which costs milliseconds and would + otherwise never run at all on a laptop that is asleep every night. +- **The next run is computed from the clock**, never from when the previous one + finished, so a slow run cannot make the schedule drift. +- **Daylight saving is wall-clock.** On the spring forward, an expression + naming an hour that does not exist that day runs at the first valid instant + after the jump — 03:00 in Kyiv fires at 04:00 — which is what vixie cron does + and the only alternative to silently missing a night once a year. On the + autumn repeat it fires once, not twice. +- **An expression that can never match is refused**, not accepted and silently + never run. `0 0 30 2 *` is a configuration error. + +The dashboard shows the next three runs beside the field, computed on the +server by the same parser that fires them — a second cron implementation in the +browser could only ever disagree with the first. + +### Configuration + +Set in the dashboard, or by environment for deployments nobody opens a +dashboard for. + +| Variable | Meaning | +|---|---| +| `CIX_DB_MAINTENANCE_CRON` | Default schedule for the database tasks | +| `CIX_DB_MAINTENANCE_MIN_FREE_PERCENT` | Waste threshold, percent of the file | +| `CIX_DB_MAINTENANCE_MIN_FREE_BYTES` | Waste threshold, absolute | + +An invalid expression is refused at startup rather than at the first tick. A +schedule saved in the dashboard overrides the environment. + +### The scheduler underneath + +`internal/schedule` is a general registry, not a database feature: a table of +named tasks, one timer armed at the earliest of them, and a handler called +in-process when a task is due. Polling and cleanup can hang off the same +machinery. + +It sleeps until the next armed run rather than polling — a server with two +daily tasks has no reason to wake every thirty seconds to be told it is not +time yet — with the wait capped at five minutes, because a suspended laptop +does not advance the monotonic clock and a timer armed for eight hours can come +back arbitrarily late. + +The one recurring job still outside it is the update check, which keeps its own +ticker: its period is `CIX_VERSION_CHECK_INTERVAL`, a released duration-valued +variable, and a duration does not survive the trip through crontab — `6h` maps +cleanly, `7h` does not exist at all. Moving it means either breaking that +variable or carrying both forms, which is a decision of its own rather than a +tidy-up. + +It is deliberately **not** a job queue. The server already has one — the `jobs` +table, with retries, dedupe and a worker — and a second persistence model beside +it would mean two places to look when something did not run. A task that wants +durable, retryable work enqueues it into `jobs`; that is the seam. Compaction is +the reason it could not simply live in the queue in the first place: it drains +that queue as part of taking the server read-only, so a trigger inside it would +be draining itself. + +The slot is claimed on disk **before** the handler runs. That is correctness, +not bookkeeping: compaction re-executes the process as its final step, and a +slot still marked due when the new process starts would fire it again, and +again. + +## Incremental auto-vacuum, and what it costs + +Incremental mode maintains pointer-map pages so free pages can be moved to the +end of the file and truncated away. Every page allocated or freed therefore +carries an extra write, and this server's hot path is bulk-inserting chunk and +symbol rows. + +Measured on an indexing-shaped workload — 120 000 wide rows inserted in batched +transactions, then a bulk delete: + +``` +none insert 1.651s delete 129ms file 71.7 MB +incremental insert 1.676s delete 133ms file 71.7 MB +``` + +**+1.5% on insert, no measurable difference elsewhere.** New databases are +created in incremental mode on the strength of that. Existing databases are +left alone: the mode is set once, on a file this server is creating, and never +again. + +That is deliberately narrower than it first appears it needs to be. SQLite +ignores the pragma on a populated database only when honouring it would mean +moving pages — going to or from `none`. Between `full` and `incremental` it +applies immediately, so setting it on every connection would have converted a +database somebody had deliberately put in full auto-vacuum, on nothing more +than an upgrade. The reclaim mode has a switch of its own; nothing else gets to +move it. + +If that 1.5% matters more than being able to reclaim space without a rebuild, +the switch turns off as readily as it turns on. + +## Monitoring + +`GET /maintenance/status` is public and reads only the state file, so it keeps +answering while sessions are unwritable and again the moment a restarted server +is listening. It cannot answer *during* the restart itself — the listener binds +at the end of startup — so a poller should render that gap as "reconnecting" +rather than as an error. The dashboard banner does exactly that. + +`/health` returns `200` with `"maintenance": true` while frozen, without +touching the database. It has to: the container healthcheck runs every 30 s +with three retries and a restart policy acts on the result, so a failing probe +here would kill the compaction it was reporting on. diff --git a/doc/SEARCH_ALGORITHM.md b/doc/SEARCH_ALGORITHM.md index 981baf0e..71874358 100644 --- a/doc/SEARCH_ALGORITHM.md +++ b/doc/SEARCH_ALGORITHM.md @@ -14,7 +14,7 @@ query string ──▶ "Represent this query for searching relevant code: " + qu llama-server sidecar (CodeRankEmbed Q8_0 GGUF) — 768-dim vector │ ▼ - chromem-go cosine search over the project's collection + cosine search over the project's vector collection │ ▼ per-chunk hits → merge windowed overlaps → group by file → top-N files @@ -55,7 +55,7 @@ and groups everything by file path. The top-N flag (`--limit`) is N ## 2. FTS5 / BM25 chunk mirror -Every chunk that lands in chromem-go also lands as a row in two +Every chunk that lands in the vector store also lands as a row in two sister SQLite tables: - `chunks_meta` — regular indexed shadow (project_path, file_path, @@ -99,7 +99,7 @@ workspace path can rely on it. ┌────────────────▼──┐ ┌──▼──────────────┐ │ dense fan-out │ │ BM25 fan-out │ │ (per-project │ │ (chunks_fts per │ - │ chromem cosine) │ │ project) │ + │ vector cosine) │ │ project) │ └────────┬──────────┘ └─────────┬───────┘ │ │ ▼ ▼ diff --git a/doc/SETUP_MACOS_NATIVE.md b/doc/SETUP_MACOS_NATIVE.md index 5e8dc59b..612f19bd 100644 --- a/doc/SETUP_MACOS_NATIVE.md +++ b/doc/SETUP_MACOS_NATIVE.md @@ -128,10 +128,26 @@ cd server && make bundle the Metal-enabled `llama-server` (llama.cpp + `libggml-metal.dylib`). The binaries land in `server/dist/cix-darwin-arm64/`. -> The bundled `llama-server` is re-signed at bundle time (commit -> `8c56fc3`) so macOS amfid doesn't kill it on first launch. If you -> see "killed: 9" on startup, re-run `make bundle` to refresh the -> signature. +Each step is skipped when it would reproduce identical output, so a +repeat `make bundle` (or `make run`) takes well under a second instead +of re-downloading ~11 MB from GitHub and re-signing 52 MB of dylibs. +Force a step when you need to: + +| Variable | Forces | +|---|---| +| `LLAMA_FORCE=1` | re-fetch llama.cpp, restage `dist/llama/`, and rebuild the bundle | +| `BUNDLE_FORCE=1` | re-copy + re-sign the bundle's `llama/` only | +| `DASHBOARD_FORCE=1` | rebuild the React dashboard | + +Verified llama.cpp archives are cached in `~/.cache/cix/llama/` +(override with `LLAMA_CACHE_DIR`), so even a forced re-fetch is +usually offline. + +> The bundled `llama-server` is re-signed whenever it is copied into +> the bundle (commit `8c56fc3`) so macOS amfid doesn't kill it on +> first launch. If you see "killed: 9" on startup, run +> `make bundle BUNDLE_FORCE=1` to refresh the signature — a plain +> `make bundle` will skip the copy, and the re-sign with it. ### Configure diff --git a/doc/TEAM_DEPLOYMENT.md b/doc/TEAM_DEPLOYMENT.md index 81afb9b1..537bcea2 100644 --- a/doc/TEAM_DEPLOYMENT.md +++ b/doc/TEAM_DEPLOYMENT.md @@ -23,7 +23,7 @@ A single `cix-server` container exposes: - `:21847` — REST API (Bearer API key) + cookie-session web dashboard at `/dashboard`, Swagger UI at `/docs`. - An embedded indexing pipeline (tree-sitter chunking → embeddings → - chromem-go vector store + SQLite FTS5/BM25 mirror). + SQLite vector store + SQLite FTS5/BM25 mirror). - An embedding backend — by default a **bundled llama.cpp sidecar** (no external calls), optionally **Voyage AI** or an **OpenAI-compatible** endpoint (see §6). diff --git a/doc/VECTORSTORE.md b/doc/VECTORSTORE.md new file mode 100644 index 00000000..bc95c451 --- /dev/null +++ b/doc/VECTORSTORE.md @@ -0,0 +1,319 @@ +# Vector store + +cix stores chunk embeddings in SQLite and searches them with a streamed +brute-force scan. This document describes the layout on disk, the one-time +migration from the previous engine, and the environment variables that tune it. + +## Why it changed + +The previous engine was [chromem-go](https://github.com/philippgille/chromem-go), +an in-memory vector database with gob-file persistence. It loads **every +document of every collection into the process heap at startup and never +evicts**. Measured on a real 312,334-document / 47-collection index, against +this implementation on the same data: + +| | chromem-go | SQLite store | +|---|---|---| +| Resident memory, idle | **2209 MB** | **19 MB** | +| Resident memory after a fan-out over all 47 collections | 2209 MB+ | **26 MB** | +| Time from process start to first answerable query | **47 s** | **≈1 ms** | +| Search latency, 74k-doc collection, k=10 / k=500 | 34 / 38 ms | 139 / 146 ms | +| Fan-out over all 312k documents, warm | ~150 ms (estimated, all in RAM) | 510 ms | +| Disk | 2.5 GB of gob files | 1.86 GB (chunk text included) | +| Import of the whole index | — | 17 s | + +Memory was proportional to the index rather than to the work. It is now +proportional to the work: nothing is loaded at open, and a query costs a few +page-cache buffers that are returned to the OS when the connection goes idle. +The trade is search latency — roughly 4x slower, and nearly unchanged by the +result limit (k=500 costs 5% more than k=10, against 11% for chromem). + +## Layout on disk + +``` +/ + chroma/ # legacy chromem-go tree, read-only, never modified + ollama// + <8-hex>/00000000.gob … # one gob per document + vectors/ # live vector store + ollama// + vectors.db # + -wal, -shm +``` + +One SQLite database per **embedding namespace** — the provider kind, model slug +and optional variant, exactly the components chromem was namespaced by +(`Config.VectorDirFor` mirrors `Config.ChromaDirFor`). Vectors of different +dimensions can therefore never share a database. + +The two trees are siblings rather than nested so the legacy files stay +untouched as a rollback path, and so reclaiming them later is one directory +removal that cannot touch a live database. + +Each namespace gets its own *directory* holding `vectors.db`, because a live +SQLite database is three files (`.db`, `-wal`, `-shm`) and the maintenance +surface — namespace scanning, size accounting, active-namespace protection — +works in terms of directories. + +## Schema + +```sql +CREATE TABLE collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE vectors ( + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + doc_id TEXT NOT NULL, + file_path TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + chunk_type TEXT NOT NULL DEFAULT '', + symbol_name TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT '', + embedding BLOB NOT NULL, -- little-endian float32, dim = len/4 + PRIMARY KEY (collection_id, doc_id) +); +CREATE INDEX idx_vec_coll ON vectors(collection_id); +CREATE INDEX idx_vec_coll_file ON vectors(collection_id, file_path); + +CREATE TABLE vector_contents ( + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + doc_id TEXT NOT NULL, + content TEXT NOT NULL, + PRIMARY KEY (collection_id, doc_id) +) WITHOUT ROWID; + +CREATE TABLE migration_state ( + collection_name TEXT PRIMARY KEY, + migrated_at TEXT NOT NULL, + docs INTEGER NOT NULL +); +``` + +Collection names (`project_`) and document IDs +(`:-:`) are **frozen compatibility +contracts** shared with the archived Python backend and with every index +already on disk. That is what lets the gob files be imported verbatim. + +**Why the id guards.** A collection id is *cached* by callers — `Store.collIDs`, +and through it the indexer — across a window in which an admin can delete the +collection. Both guards close a hole that window opens: + +- Without `AUTOINCREMENT`, `collections.id` is the rowid and SQLite reuses the + largest free one. Deleting the highest-numbered collection hands its id to the + *next* collection created, so any row that outlived the delete is silently + adopted by an unrelated project and search answers one project's query with + another project's chunks. +- Without the foreign keys, a late upsert commits rows whose `collection_id` has + no `collections` row. `ListCollections` joins **from** `collections`, so those + rows are invisible to every count, every size figure and the orphan sweep — + they simply hold disk forever. With the constraint the upsert fails loudly + instead; see [Schema versions](#schema-versions). + +## Schema versions + +`PRAGMA user_version` carries the schema version, and `openDB` upgrades an older +file before the connection pool is created. + +| version | shape | +|---|---| +| 0 | The original schema. Nothing stamped `user_version`, so a v1 file reports 0. Plain rowid collection ids, no foreign keys, `auto_vacuum` off. | +| 2 | `AUTOINCREMENT` ids, `ON DELETE CASCADE` foreign keys, `auto_vacuum=INCREMENTAL`. | + +None of those three can be reached with `ALTER TABLE`: `AUTOINCREMENT` and +`REFERENCES` live in the table's declared SQL, and `auto_vacuum` is only +honoured on a database with no tables yet or after a full `VACUUM`. So the +upgrade is a **rebuild**: a sibling temp file gets the v2 schema and the v2 file +pragmas, the data is copied in (`ATTACH` + `INSERT … SELECT`), the file is +fsynced and renamed over the original, and the stale `-wal`/`-shm` are removed. +One pass delivers all three — a rebuild *is* the vacuum. Free space is checked +first; the peak requirement is one extra copy of the file. Measured: a 152 MB +database rebuilds in **0.32 s**, so the 1.86 GB reference index is a few +seconds of one-time boot delay, logged at `warn`. + +A v1 file may already hold orphan rows — that is the leak v2 exists to stop. They +cannot be copied into a database that enforces the constraint, so the rebuild +filters them out and logs how many it dropped. Nothing loses visible data: those +rows were already unreachable through `collections`. + +**When a collection is deleted mid-upsert**, `UpsertChunks` now returns an error +wrapping `ErrCollectionDeleted` instead of leaking rows. It does not retry: the +stale id is dropped from the cache so a later call resolves it afresh, but +whether to re-create the collection is the caller's decision. Rows committed by +earlier batches of the same call are already gone — cascaded away with the +`collections` row. + +**Why chunk text lives in its own table.** Storing it duplicates `chunks_fts` +on disk, deliberately: it keeps the package self-contained and +`SearchResult.Content` unchanged with no cross-database wiring. But it cannot +live in `vectors`. A multi-kilobyte `TEXT` column pushes a row past SQLite's +local-payload limit, and SQLite then keeps only ~1 kB of the row in the table +page and spills the rest — *including the embedding* — into an overflow chain, +roughly doubling the pages a scan touches. Kept apart, a `vectors` row is +~3.2 kB and two of them share an 8 KiB page. Content is read only for the K +winners of a search: one extra lookup per result. + +## Search + +``` +SELECT rowid, embedding FROM vectors INDEXED BY idx_vec_coll + WHERE collection_id = ? [AND ] +``` + +Rows stream past a dot product (embeddings are stored L2-normalised, so cosine +similarity *is* the dot product) into a top-K min-heap that rejects a losing +row with one comparison. Metadata and chunk text are fetched afterwards, for +the winners only. + +`INDEXED BY` is not an optimisation hint, it is a guarantee, and *which* index +matters. Measured on the real index, scanning its largest (74k-row) collection: + +| driven by | | | +|---|---|---| +| `idx_vec_coll` | **137 ms** | keys are `(collection_id, rowid)` | +| `idx_vec_coll_file` | 244 ms | keys are `(collection_id, file_path, rowid)` | +| no index | 267 ms | walks all 312k rows and discards 76% of them | + +Delete-by-file followed by reinsert — what the file watcher does on every save +— appends the new rows at the end of the table, so a collection's rows stop +being contiguous and a plain table scan degrades without bound. Both indexes +fix that (they visit only the collection's own rows), but SQLite appends the +rowid to every index key, so `idx_vec_coll` also hands the rows back in *table* +order and the row lookups stay sequential. The file-path index scatters them +across the collection's whole rowid span, for 1.8x the time. +`TestScanUsesCollectionIndex` pins the plan. + +The metadata filter (`where`) mirrors chromem's semantics exactly, including +the two odd cases: an unknown key with a non-empty value matches nothing, and +an unknown key with an empty value matches everything. + +**Concurrency.** One scan per query, and a process-wide semaphore caps +concurrent scans at `NumCPU`. Splitting a single query across workers was +measured to buy nothing in the low-memory configuration (109 ms at 1 worker vs +110 ms at 4) — the scan is bound by per-row streaming cost, not arithmetic. +What needs bounding is fan-out: thirty concurrent agent queries must queue on a +handful of scanners rather than spawn a hundred threads and a hundred page +caches. + +## Pragmas + +Applied explicitly on each new connection, never through `_pragma=` DSN +parameters: **`modernc.org/sqlite` sorts DSN pragmas lexicographically** rather +than applying them in the order written, so on a fresh database +`journal_mode(WAL)` always runs before `page_size(...)`, the first WAL +statement materialises the file, and the page size is silently ignored. + +| pragma | value | why | +|---|---|---| +| `page_size` | 8192 (fresh databases only) | 4 KiB wastes ~23% of every page (one row per page). 16 KiB is one byte-class over `modernc.org/memory`'s slab limit, so every page buffer costs an `mmap` + `munmap` — measured at 24% of all CPU during a scan. | +| `journal_mode` | WAL | Readers do not block the indexer. | +| `synchronous` | NORMAL | | +| `busy_timeout` | 10 s | | +| `journal_size_limit` | 64 MB | A checkpoint rewinds the WAL but by default leaves the FILE at its high-water mark forever. The legacy import commits a whole collection at once — measured a permanent 159 MB `-wal` beside a 158 MB database — and that sidecar is counted in the "Vector store" row of the Resources screen. With a limit the checkpoint truncates it back. Steady-state indexing commits every 500 chunks and never reaches the cap. | +| `cache_size` | driver default (2 MB) | Measured: raising it buys no latency (the working set dwarfs any realistic cache) and it is **per connection**, so it multiplies resident memory. | +| `mmap_size` | off | Opt-in, see below. | +| `foreign_keys` | ON | Per **connection**, and off by default in SQLite — a declared `REFERENCES` clause that is never enabled is just a comment. It is what stops an upsert holding a stale collection id from committing rows no `collections` row joins to. | +| `auto_vacuum` | INCREMENTAL (set at creation) | The Resources screen reports bytes reclaimed when a collection is deleted; without this the pages only reach the freelist, the file never shrinks, and `df` never confirms the claim. INCREMENTAL rather than FULL because the reclaim is driven explicitly (`PRAGMA incremental_vacuum` after a collection delete) and never on the watcher's delete-and-reinsert path — measured there: 100 delete+reinsert cycles over 72k rows grew the file by 3.8 MB and ended with an empty freelist, i.e. free page recycling already handles it. | + +Idle pooled connections are closed after 30 seconds. This is the mechanism that +makes idle memory collapse: SQLite's page cache lives in `modernc.org/memory` +arenas obtained by raw `mmap`, outside the Go heap, so `runtime.GC()` cannot +return it — closing the connection can. + +## Environment variables + +| variable | default | meaning | +|---|---|---| +| `CIX_VECTORS_DIR` | sibling of `CIX_CHROMA_PERSIST_DIR`, i.e. `<...>/vectors` | Container for the per-namespace databases. The default follows the chroma container so a deployment that only overrides `CIX_CHROMA_PERSIST_DIR` still lands its vectors on the same persistent volume. | +| `CIX_VECTOR_MMAP_SIZE` | `0` (off) | `PRAGMA mmap_size` in bytes. Cuts search latency by roughly 40% and costs resident memory: every connection maps the database file and mapped pages count in RSS (measured 1.0–3.2 GB under fan-out). The pages are clean and instantly reclaimable, so this is a reasonable trade on a memory-rich host — and not compatible with a tight memory ceiling. | +| `CIX_CHROMA_PERSIST_DIR` | `/chroma` | Still read: it is where the legacy gob files live and where the one-time import reads from. | + +## Migration from chromem-go + +On startup, for the ACTIVE namespace only, the store imports every collection +of the matching chromem directory that is not already recorded in +`migration_state`. Behaviour: + +- **Fresh install** — no chromem directory, nothing logged, nothing done. +- **Existing install** — one transaction per collection, which also writes the + collection's `migration_state` row. An interrupted import therefore redoes + exactly the collection it was in the middle of and never duplicates a + finished one. Progress is logged at `warn` every 10 collections: + `migrating vector store collections=12/47 docs=…`. Warn, not info, because + production runs at warn level and the HTTP listener only comes up once the + store is open — at info the operator watches a server that answers nothing + and says nothing for the whole import, which has repeatedly been read as + "it is down" and answered with a restart. +- **Streamed, not buffered** — decode workers feed a channel and the writer + drains it into the transaction 2000 documents at a time. Decoding a whole + collection first cost ~9 kB of live heap per document (268 MB peak for a 30k + document collection, ~1.8 GB for a 200k monorepo): the first boot after the + upgrade demanded exactly the memory this store exists to give back. Peak heap + now scales with the batch, not with the collection. +- **Reference figures** — 312,334 documents across 47 collections imported in + 17 s, producing a 1.86 GB database from a 2.5 GB gob tree. +- **Before starting**, free space is checked at 0.9x the gob tree: 0.74x is the + measured database (1.86 GB from 2.5 GB) and the rest is the WAL. Every page a + transaction touches sits in the WAL until it commits, so a per-collection + transaction still means a WAL of roughly one collection whatever the write + batch is — `journal_size_limit` truncates it afterwards, but the import has to + fit through the peak. An obviously impossible import fails the boot with a + clear message rather than half-writing. +- **Nothing under the chromem directory is ever modified or removed.** It is + the rollback path: downgrading to a build that uses chromem finds its data + exactly as it left it. +- A collection deleted afterwards (an orphan reclaimed from the Resources + screen) keeps its `migration_state` row, so the next boot does not re-import + what an admin just removed. +- Switching the embedding provider or model at runtime opens the new + namespace's database and imports that namespace's gob files the same way. + +The server does not link chromem-go at all. The importer decodes the gob files +through local mirror structs; chromem is a test-only dependency, kept because a +fixture written by the real thing is the only evidence that those structs still +match what is on disk. + +### Reclaiming the legacy files + +Two categories on the admin **Resources** screen cover the gob tree, and the +split matters: + +- **Abandoned provider namespaces** (`stale_namespaces`) takes the namespaces + of models that are no longer in use, in either tree. The ACTIVE namespace is + deliberately protected there, in both trees — its gob files are the rollback + path. +- **Legacy chromem data** (`legacy_chromem`) is how that protection is + released, on purpose. It offers the active namespace's chromem directory — + 2.5 GB on the reference install — once every collection in it is provably in + `vectors.db`. + +The rules of the second one: + +- A namespace is listed only when it is **fully imported**: every collection + directory in the tree has a `migration_state` row in that namespace's + database. Anything less — a migration still running, a directory the importer + could not read, a missing or unreadable `vectors.db` — and the namespace is + not offered at all (not offered-and-disabled: a disabled row would still + advertise gigabytes that are not garbage yet). The analysis warnings say + which case it was. +- It is **never pre-selected**, and the description states plainly that this is + irreversible and gives up the ability to roll back to a pre-SQLite server + version. +- Disk only. Nothing about the legacy tree is in memory — the new store loads + nothing at open — so `estimated_ram_bytes` stays zero. Nothing sets that field + any more at all; it is deprecated and omitted from the wire, kept only so an + older dashboard build does not break on it. +- The full-migration check is repeated immediately before the delete. Between + the analysis and the confirm, an embedding-model switch can reopen this + namespace and start a fresh import; the item is then skipped rather than + deleted. +- A running index or clone job does **not** hold the category back, unlike + orphaned collections. This binary never writes the gob tree — indexing writes + `vectors.db` and nothing else — so a job cannot make these files matter + again. The only thing that can is an in-flight import, which the re-check + asks about directly. + +Deleting the tree is one recursive directory removal and changes nothing else: +`migration_state` rows are kept, so the next boot finds no legacy directory, +imports nothing, and starts normally. diff --git a/doc/WORKSPACES.md b/doc/WORKSPACES.md index 0c400bcd..ec8cf1ce 100644 --- a/doc/WORKSPACES.md +++ b/doc/WORKSPACES.md @@ -239,7 +239,7 @@ The original `PR4–PR7` placeholders have all landed on `develop`: - **PR4** (`f244643`) — Intra-project call-graph extraction (`call_edges` table) + eval harness. - **PR5** (`ec32744`) — Louvain community detection per workspace + - workspace centroid embeddings in a dedicated chromem collection. + workspace centroid embeddings in a dedicated vector collection. - **PR6** (`207bfaf`) — Two-stage workspace search endpoint (`POST /api/v1/workspaces/{id}/search`). Hybrid BM25 + dense ranking with project-level gating — see [`SEARCH_ALGORITHM.md`](SEARCH_ALGORITHM.md#3-workspace-hybrid-search). diff --git a/doc/openapi.yaml b/doc/openapi.yaml index 5ded6b48..f915431d 100644 --- a/doc/openapi.yaml +++ b/doc/openapi.yaml @@ -602,6 +602,468 @@ paths: "403": $ref: "#/components/responses/Forbidden" + /api/v1/admin/resources: + get: + operationId: getResourceUsage + tags: [admin] + summary: Report memory and disk usage (admin only) + description: | + What the server is using right now: Go heap figures, resident set size + where the platform exposes it cheaply, the four storage locations with + their sizes and free space, and the resident vector-store totals. + + The vector store is an in-memory database — every document of every + collection is decoded into the heap at startup and never evicted — so + `memory.heap_alloc_bytes` tracks the size of the loaded index almost + one-for-one, and `vector_store.documents` is the number that explains + it. Document counts are read from memory and free; the directory sizes + are real filesystem walks and can take several seconds on a large + index, so render this behind a loading state rather than polling it. + responses: + "200": + description: Current usage + content: + application/json: + schema: + $ref: "#/components/schemas/ResourceUsage" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "503": + $ref: "#/components/responses/ResourcesUnavailable" + + /api/v1/admin/resources/analyze: + post: + operationId: analyzeReclaimable + tags: [admin] + summary: Find reclaimable garbage (admin only) + description: | + Reconciles what is on disk and in memory against what the database + still knows about, and returns the reclaimable garbage grouped into + categories the admin can select individually. + + Synchronous and expensive — it walks the vector store and the clone + directory. Concurrent calls collapse into a single scan. + + The returned `analysis_id` is passed back to + `POST /api/v1/admin/resources/clean`. It is an identity for "the + picture the admin was shown", not a safety mechanism: every item is + re-validated against live state immediately before it is deleted, so a + project that comes back between analyze and clean is skipped rather + than wiped. + responses: + "200": + description: Analysis complete + content: + application/json: + schema: + $ref: "#/components/schemas/ReclaimAnalysis" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "503": + $ref: "#/components/responses/ResourcesUnavailable" + + /api/v1/admin/resources/clean: + post: + operationId: cleanResources + tags: [admin] + summary: Delete the selected reclaimable categories (admin only) + description: | + Deletes the selected categories from a previous analysis. + + Returns `200` even when individual items failed or were skipped — the + per-category counts carry that detail, and failing the whole call would + hide the items that were successfully reclaimed. `409` means the + analysis expired or was already spent; re-run analyze. + + Deleting orphaned collections releases heap immediately — the server + forces a GC afterwards, so `heap_alloc_bytes` drops right away. How far + `rss_bytes` follows is platform-dependent: on Linux the pages go back to + the OS and resident memory drops with the heap; on macOS they are marked + reclaimable and stay resident until something else needs them. + Everything else is disk only. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CleanRequest" + responses: + "200": + description: Clean finished (possibly with per-item failures) + content: + application/json: + schema: + $ref: "#/components/schemas/CleanResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/Unprocessable" + "503": + $ref: "#/components/responses/ResourcesUnavailable" + + /api/v1/admin/database: + get: + operationId: getDatabaseState + tags: [admin] + summary: Report SQLite size, wasted space and compaction advice (admin only) + description: | + Deleting rows does not shrink a SQLite file. The pages go on the + freelist and are reused by later writes, so a database that has lost + more data than it has since gained stays large and mostly empty. This + endpoint reports how much of the file is waste and whether it is worth + doing anything about it. + + Cheap: `page_count` and `freelist_pages` are header reads, not scans, + so this is safe to poll. + + `auto_vacuum` is a property of the database file itself, not a stored + setting — it is read live, so it always reflects reality even on a + server that was upgraded into this feature. `none` means the file can + only be shrunk by a full rebuild; `incremental` means free pages can + additionally be returned to the filesystem in small bounded chunks + (see `POST /api/v1/admin/database/reclaim`). + + `blocked_reason` is non-null when compaction cannot start right now — + an indexing or clone job is in flight, or the filesystem does not have + room for the copy. Render it instead of enabling the button. + responses: + "200": + description: Current database state + content: + application/json: + schema: + $ref: "#/components/schemas/DatabaseState" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "503": + $ref: "#/components/responses/ResourcesUnavailable" + + /api/v1/admin/database/compact: + post: + operationId: compactDatabase + tags: [admin] + summary: Rebuild the database to reclaim free pages (admin only) + description: | + Starts a background rebuild and returns immediately with `202`. Poll + `GET /maintenance/status` for progress. + + What the admin is agreeing to, and what the confirmation dialog must + say before this is called: + + * For the long part the server is **read-only**, not down. Search, + browsing and every read keep working. New logins and every write are + rejected with `503` and a `Retry-After`; indexing and scheduled + polling pause. Existing sessions and API keys keep working — their + last-seen timestamps simply stop being refreshed for the duration, + which is invisible against a 14-day session lifetime. + * Then the server **restarts itself** to adopt the new file, and is + fully unavailable while it comes back up. + * Expect roughly a minute per 8 GB on a warm SSD for the copy, plus + the usual startup time; `estimated_seconds` on `DatabaseState` + carries the current estimate. + + The restart is not a fallback, it is the mechanism. Swapping the file + under a live server would leave every service holding an open handle + to the old one, so the swap is performed at boot, before anything + opens the database — no pool, no services, no concurrency. The process + re-executes itself rather than exiting, so a container keeps its PID 1 + and no restart policy or supervisor is involved. + + The read-only window matters because the rebuild copies a *snapshot*: + anything committed after the copy begins is absent from the new file, + and swapping it in would silently discard those writes. It is enforced + in three layers rather than one, because the obvious single mechanism + is wrong in both available forms. + + A route gate refuses the endpoints that write, and is what keeps + latency sane — it is classified per route, never by HTTP method, since + search is a POST and is one of the things that must keep working. + Background work is stopped and drained before the copy starts. Finally + the compactor holds a write transaction on a connection of its own, so + anything that slips past the first two layers is refused by SQLite + itself rather than landing outside the snapshot. + + Compaction does not change the database's reclaim mode. It reclaims + space and leaves the mode exactly as it found it — that is a separate + setting, changed through + `PUT /api/v1/admin/database/auto-vacuum`. + + Crash safety: progress is journalled to a state file beside the + database, written atomically, and every combination of that state and + the files actually present on disk maps to exactly one recovery + action. An interrupted copy is discarded; an interrupted swap is + carried forward or rolled back. The original is deleted last, so no + interruption leaves the server without a database. Nothing is resumed + — the operation is all-or-nothing and is simply re-run. + + The state lives in a file rather than a table precisely because the + database is the thing being replaced: a row describing the operation + could not survive the swap, could not be written during the read-only + window, and could not be read back during the restart. + responses: + "202": + description: Compaction started + content: + application/json: + schema: + $ref: "#/components/schemas/MaintenanceOperation" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + description: | + Already running, or held back because clone/index jobs are in + flight. `DatabaseState.blocked_reason` carries the same detail. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "507": + $ref: "#/components/responses/InsufficientStorage" + "503": + $ref: "#/components/responses/ResourcesUnavailable" + + /api/v1/admin/database/reclaim: + post: + operationId: reclaimFreePages + tags: [admin] + summary: Return free pages to the filesystem in bounded chunks (admin only) + description: | + Runs `PRAGMA incremental_vacuum`, which moves free pages to the end of + the file, followed by a WAL checkpoint. Synchronous, bounded and + cheap: it holds an ordinary write lock for the pages it moves, not an + exclusive lock on the whole database, so it does not need a + maintenance window. + + The checkpoint is part of the operation, not an extra. In WAL mode the + page moves and the truncation both land in the log, so the database + file does not shrink until the log is folded back in — without it a + small reclaim reports bytes freed while the file on disk is unchanged. + + Requires the file to be in incremental auto-vacuum mode; `409` + otherwise, with compaction as the remedy. + + This reclaims space but does not defragment — a full compaction still + rebuilds the file and improves read locality. The point of incremental + reclaim is to make that rare rather than routine. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/ReclaimRequest" + responses: + "200": + description: Reclaim finished + content: + application/json: + schema: + $ref: "#/components/schemas/ReclaimResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + description: The database is not in incremental auto-vacuum mode + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "503": + $ref: "#/components/responses/ResourcesUnavailable" + + /api/v1/admin/database/auto-vacuum: + put: + operationId: setAutoVacuumMode + tags: [admin] + summary: Change the database's reclaim mode (admin only) + description: | + Incremental mode lets free pages be returned to the filesystem without + a rebuild — it is what makes + `POST /api/v1/admin/database/reclaim` possible. It is not free: it + maintains pointer-map pages, so every page allocated or freed carries + an extra write, measured at +1.5% on an indexing-shaped workload. + + **Asking for the mode the database is already in does nothing** and + answers `200`. Only an actual change costs anything. + + A change costs a full rebuild, in either direction, because that is + the only way SQLite can change the mode of a populated database. So a + `202` here means the same interruption as + `POST /api/v1/admin/database/compact`: a read-only window, then a + restart. The space that rebuild happens to reclaim is a side effect, + not the point. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AutoVacuumRequest" + responses: + "200": + description: | + The database is already in the requested mode. Nothing was done and + the returned operation is `idle`. + content: + application/json: + schema: + $ref: "#/components/schemas/MaintenanceOperation" + "202": + description: A rebuild has started to apply the change. + content: + application/json: + schema: + $ref: "#/components/schemas/MaintenanceOperation" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + description: | + Already running, or held back because clone/index jobs are in + flight. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "422": + $ref: "#/components/responses/Unprocessable" + "507": + $ref: "#/components/responses/InsufficientStorage" + "503": + $ref: "#/components/responses/ResourcesUnavailable" + + /api/v1/admin/schedules: + get: + operationId: listSchedules + tags: [admin] + summary: List every recurring task and when it next runs (admin only) + description: | + One entry per task the server knows how to run on a schedule. The + timing is a crontab expression, and `next_runs` is computed by the + same parser that will actually fire it — a client must not evaluate + the expression itself, or its preview and the server's behaviour can + disagree. + + `configured` is false while a task is still on its built-in or + environment default, which is how an upgraded server reports + automation nobody has opted into. + responses: + "200": + description: Registered tasks + content: + application/json: + schema: + type: object + required: [tasks] + properties: + tasks: + type: array + items: + $ref: "#/components/schemas/ScheduledTask" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "503": + $ref: "#/components/responses/ResourcesUnavailable" + + /api/v1/admin/schedules/{name}: + put: + operationId: updateSchedule + tags: [admin] + summary: Change when a recurring task runs (admin only) + description: | + Absent fields are left unchanged. + + An expression that parses but can never match — 30 February is the + honest example — is refused with `422` rather than accepted as a + schedule that silently never fires. Saving re-arms the task + immediately, so the next run returned here is the one the server will + actually keep. + + Timing is interpreted in the server's local timezone. Crontab + semantics apply: a slot that passes while the process is not running + is not queued up and fired late, except for tasks that declare + `catch_up` — those are cheap enough that running one late is better + than a laptop asleep at 00:00 never running them at all. + parameters: + - name: name + in: path + required: true + schema: { type: string } + description: Task identifier, e.g. `db.reclaim`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduleUpdate" + responses: + "200": + description: The task as it now resolves + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduledTask" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/Unprocessable" + "503": + $ref: "#/components/responses/ResourcesUnavailable" + + + /maintenance/status: + get: + operationId: getMaintenanceStatus + tags: [system] + summary: Progress of a running maintenance operation (public) + description: | + Public and deliberately so. It reads a state file next to the database + and touches neither the database nor the session table, so it answers + throughout the read-only window and again the moment the restarted + server is listening. Anything authenticated would `401` while sessions + are unwritable, and anything DB-backed would block behind the freeze. + + It cannot answer *during* the restart itself: the router is built + after the database is opened and the listener binds at the end of + startup, so a client sees a connection failure until the server is + back. A poller is expected to render that as "reconnecting" rather + than as an error — the journal it reads is durable, so the first + successful poll after the gap reports what happened. + + This is also how the result of a compaction reaches the dashboard at + all: the outcome is only known after the snapshot the new database was + built from, so it exists in the state file and nowhere else. + + `phase` is `idle` when nothing is happening. `interrupted` means a + previous run did not finish and the next boot brought the database + back to a consistent state; it is informational, not an error to + clear. + responses: + "200": + description: Current maintenance state + content: + application/json: + schema: + $ref: "#/components/schemas/MaintenanceOperation" + /api/v1/admin/embedding-providers: get: operationId: listEmbeddingProviders @@ -1081,7 +1543,7 @@ paths: summary: Semantic (vector) search description: | Embeds the query and runs an approximate nearest-neighbour search - against the project's chromem-go collection. Results are + against the project's vector collection. Results are post-filtered by `min_score`, `paths` (whitelist, prefix-OR-substring match), `excludes` (blacklist, same matching), and `languages` — then merged into per-file groups and ranked by best match score. @@ -2108,7 +2570,7 @@ paths: summary: Hybrid BM25+dense search across all repos in a workspace description: | Embeds the query, then fans out two parallel sub-queries per - project: dense (chromem cosine) and sparse (SQLite FTS5 BM25 + project: dense (cosine over stored vectors) and sparse (SQLite FTS5 BM25 over chunks_fts). Per-project the two ranked lists are fused via Reciprocal Rank Fusion (k=60). @@ -3159,6 +3621,23 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + InsufficientStorage: + description: | + Not enough free space to hold the compacted copy alongside the + original. `DatabaseState.required_disk_bytes` and `free_disk_bytes` + carry the numbers. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + ResourcesUnavailable: + description: | + Resource accounting is not wired on this server (no config in the + handler dependencies — happens in partial-test contexts). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" WorkspacesDisabled: description: | Required service is not configured on this server (commonly an @@ -3601,6 +4080,497 @@ components: type: string description: The CIX_GGUF_CACHE_DIR that was scanned. Empty list with non-empty cache_dir = no .gguf files found. + MemoryUsage: + type: object + required: + [ + heap_alloc_bytes, + heap_inuse_bytes, + heap_idle_bytes, + heap_released_bytes, + sys_bytes, + num_gc, + num_goroutine, + ] + description: | + Go runtime memory. heap_alloc_bytes is the meaningful figure for this + server: the vector store lives entirely in the heap, so it tracks the + size of the loaded index. sys_bytes and rss_bytes run higher because + the runtime holds freed spans before returning them to the OS. + properties: + heap_alloc_bytes: { type: integer, format: int64, minimum: 0 } + heap_inuse_bytes: { type: integer, format: int64, minimum: 0 } + heap_idle_bytes: { type: integer, format: int64, minimum: 0 } + heap_released_bytes: { type: integer, format: int64, minimum: 0 } + sys_bytes: { type: integer, format: int64, minimum: 0 } + num_gc: { type: integer, format: int64, minimum: 0 } + num_goroutine: { type: integer, minimum: 0 } + rss_bytes: + type: integer + format: int64 + description: | + Current resident set size. Absent where it cannot be read without + cgo (macOS keeps it behind mach's task_info) — render "n/a", never + treat a missing field as zero. + peak_rss_bytes: + type: integer + format: int64 + description: | + High-water resident set size from getrusage. Available on Linux and + macOS. It only ever rises, so it must be labelled as a peak and + never substituted for rss_bytes. + + DiskUsage: + type: object + required: [id, label, path, exists] + properties: + id: + type: string + enum: [sqlite, chroma, repos, gguf] + label: { type: string } + path: { type: string } + exists: { type: boolean } + used_bytes: + type: integer + format: int64 + description: | + Size of the tree. Absent when it could not be walked, which keeps + "unreadable" distinguishable from "empty". The SQLite entry + includes the -wal and -shm sidecars. + fs_total_bytes: { type: integer, format: int64 } + fs_free_bytes: { type: integer, format: int64 } + + VectorStoreUsage: + type: object + required: [collections, documents, active_namespace_path] + description: Resident vector-store totals, read from memory — no I/O. + properties: + collections: { type: integer, minimum: 0 } + documents: { type: integer, format: int64, minimum: 0 } + active_namespace_path: { type: string } + + ResourceUsage: + type: object + required: [generated_at, memory, disks, vector_store] + properties: + generated_at: { type: string, format: date-time } + memory: { $ref: "#/components/schemas/MemoryUsage" } + disks: + type: array + items: { $ref: "#/components/schemas/DiskUsage" } + vector_store: { $ref: "#/components/schemas/VectorStoreUsage" } + + ReclaimCategoryId: + type: string + enum: + [ + orphan_collections, + orphan_repos, + stale_namespaces, + legacy_chromem, + stale_jobs, + unused_models, + ] + + ReclaimItem: + type: object + required: [key, label, size_bytes] + properties: + key: + type: string + description: Server-side identity — collection name, path_hash, directory or model file. + label: { type: string } + detail: { type: string } + size_bytes: { type: integer, format: int64, minimum: 0 } + + ReclaimCategory: + type: object + required: + [ + id, + label, + description, + item_count, + size_bytes, + default_selected, + destructive, + items, + items_truncated, + ] + properties: + id: { $ref: "#/components/schemas/ReclaimCategoryId" } + label: { type: string } + description: + type: string + description: | + Server-authored explanation, including why a category is disabled + when it is. Render it as-is; the reasoning lives on the server so + the rules are decided in one place. + item_count: + type: integer + minimum: 0 + description: Total items found — may exceed the length of `items`. + size_bytes: { type: integer, format: int64, minimum: 0 } + estimated_ram_bytes: + type: integer + format: int64 + deprecated: true + x-deprecated-reason: >- + Always absent since the vector store moved off the Go heap; kept + for wire compatibility with older dashboard builds and slated for + removal in the next breaking API revision. + description: | + Deprecated, and always absent. It reported heap attributable to + these items back when the vector store held every document in + memory; nothing has set it since the store moved to SQLite, whose + footprint is not proportional to what it holds. Kept in the schema + only so an older dashboard build does not break on a missing field, + and a candidate for removal in the next breaking API revision. + Clients must not display it. + default_selected: + type: boolean + description: Whether the dashboard should pre-tick this category. + destructive: + type: boolean + description: Expensive or impossible to regenerate; the confirm dialog warns extra. + items: + type: array + description: A sample for display, capped server-side. Cleaning always acts on the full set. + items: { $ref: "#/components/schemas/ReclaimItem" } + items_truncated: { type: boolean } + + ReclaimAnalysis: + type: object + required: + [ + analysis_id, + generated_at, + expires_at, + duration_ms, + total_reclaimable_bytes, + categories, + ] + properties: + analysis_id: { type: string } + generated_at: { type: string, format: date-time } + expires_at: { type: string, format: date-time } + duration_ms: { type: integer, minimum: 0 } + total_reclaimable_bytes: { type: integer, format: int64, minimum: 0 } + categories: + type: array + items: { $ref: "#/components/schemas/ReclaimCategory" } + warnings: + type: array + description: Non-fatal problems, e.g. a directory that could not be read. + items: { type: string } + + CleanRequest: + type: object + required: [analysis_id, categories] + properties: + analysis_id: { type: string } + categories: + type: array + minItems: 1 + items: { $ref: "#/components/schemas/ReclaimCategoryId" } + + CleanOutcome: + type: object + required: [key, reason] + properties: + key: { type: string } + reason: { type: string } + + CleanCategoryResult: + type: object + required: + [id, deleted_count, skipped_count, failed_count, reclaimed_bytes] + properties: + id: { $ref: "#/components/schemas/ReclaimCategoryId" } + deleted_count: { type: integer, minimum: 0 } + skipped_count: + type: integer + minimum: 0 + description: Re-validation at delete time said it was no longer safe. + failed_count: { type: integer, minimum: 0 } + reclaimed_bytes: { type: integer, format: int64, minimum: 0 } + skipped: + type: array + items: { $ref: "#/components/schemas/CleanOutcome" } + failures: + type: array + items: { $ref: "#/components/schemas/CleanOutcome" } + + CleanResult: + type: object + required: + [started_at, finished_at, duration_ms, reclaimed_bytes, categories] + properties: + started_at: { type: string, format: date-time } + finished_at: { type: string, format: date-time } + duration_ms: { type: integer, minimum: 0 } + reclaimed_bytes: { type: integer, format: int64, minimum: 0 } + categories: + type: array + items: { $ref: "#/components/schemas/CleanCategoryResult" } + + DatabaseState: + type: object + required: + [ + path, + file_bytes, + wal_bytes, + page_size, + page_count, + freelist_pages, + reclaimable_bytes, + reclaimable_percent, + auto_vacuum, + verdict, + verdict_reason, + free_disk_bytes, + required_disk_bytes, + estimated_seconds, + ] + properties: + path: { type: string } + file_bytes: { type: integer, format: int64, minimum: 0 } + wal_bytes: + type: integer + format: int64 + minimum: 0 + description: | + Size of the `-wal` sidecar. Large on its own is not a problem — + it is checkpointed automatically — but it is the cheapest thing + to reclaim when it is not. + page_size: { type: integer, minimum: 0 } + page_count: { type: integer, format: int64, minimum: 0 } + freelist_pages: + type: integer + format: int64 + minimum: 0 + description: Pages that are allocated but hold no data. + reclaimable_bytes: + type: integer + format: int64 + minimum: 0 + description: "`freelist_pages` × `page_size` — waste, not a projection of the final size." + reclaimable_percent: { type: number, minimum: 0, maximum: 100 } + auto_vacuum: + type: string + enum: [none, incremental, full] + description: | + Read live from the database file, never from stored settings, so + it is correct on any server regardless of how it got here. + verdict: + type: string + enum: [ok, recommended, urgent] + verdict_reason: + type: string + description: One sentence explaining the verdict, safe to render as-is. + free_disk_bytes: { type: integer, format: int64, minimum: 0 } + required_disk_bytes: + type: integer + format: int64 + minimum: 0 + description: | + Room a compaction needs: the copy is written alongside the + original, so both exist at once. + estimated_seconds: + type: integer + minimum: 0 + description: | + Estimated copy time, excluding the restart. Derived from the + database size and a measured throughput figure, so it is an + order-of-magnitude guide rather than a promise. + blocked_reason: + type: string + nullable: true + description: | + Why compaction cannot start right now — an in-flight clone or + index job, insufficient disk, or an operation already running. + Null when it can start. + operation: + allOf: [{ $ref: "#/components/schemas/MaintenanceOperation" }] + nullable: true + description: The running or most recent operation, if any. + + AutoVacuumRequest: + type: object + required: [mode] + properties: + mode: + type: string + enum: [none, incremental] + + MaintenanceOperation: + type: object + required: [run_id, kind, phase] + properties: + run_id: + type: string + description: | + Identifies one attempt. It changes on every run, so a client can + tell a fresh operation from a stale state file left by a process + that did not survive. + kind: + type: string + enum: [compact, reclaim, checkpoint] + phase: + type: string + enum: + [ + idle, + preparing, + copying, + ready_to_swap, + swapping, + restarting, + done, + failed, + interrupted, + ] + description: | + `preparing` is draining background work; `copying` is the + read-only window; `ready_to_swap` onwards happens around and + during the restart. `interrupted` is a previous run that did not + finish — the database was brought back to a consistent state and + the operation can simply be re-run. + started_at: + type: string + format: date-time + nullable: true + description: | + Absent when nothing has run. A zero date would read as a bug + rather than as "nothing has happened". + finished_at: { type: string, format: date-time, nullable: true } + bytes_total: + type: integer + format: int64 + nullable: true + description: Expected size of the copy, for a progress bar. + bytes_done: { type: integer, format: int64, nullable: true } + percent: { type: number, minimum: 0, maximum: 100, nullable: true } + freed_bytes: { type: integer, format: int64, nullable: true } + auto_vacuum: + type: string + enum: [none, incremental, full] + nullable: true + description: | + The reclaim mode this run leaves the database in. `full` never + appears on a request — it cannot be set through this API — but a + compaction of a database already in that mode preserves it, and + says so here. + message: + type: string + nullable: true + description: Human-readable description of the current step. + error: { type: string, nullable: true } + events: + type: array + description: | + Ordered trail of what happened, most recent last. Appended to a + file beside the database, so it spans the restart that the + operation performs — the server log alone is split across two + process lifetimes, and the interesting half is usually the one + that did not come back. Truncated to the most recent entries; + the full trail stays on disk. + items: + $ref: "#/components/schemas/MaintenanceEvent" + + MaintenanceEvent: + type: object + required: [at, level, phase, message] + properties: + at: { type: string, format: date-time } + level: { type: string, enum: [info, warn, error] } + phase: { type: string } + message: { type: string } + + ReclaimRequest: + type: object + properties: + max_pages: + type: integer + minimum: 1 + description: | + Upper bound on pages returned to the filesystem in this call. + Omit to drain the whole freelist. Bounding it keeps the write + lock short on a database with a very large freelist. + + ReclaimResult: + type: object + required: [pages_freed, bytes_freed, file_bytes, freelist_pages] + properties: + pages_freed: { type: integer, format: int64, minimum: 0 } + bytes_freed: { type: integer, format: int64, minimum: 0 } + file_bytes: { type: integer, format: int64, minimum: 0 } + freelist_pages: + type: integer + format: int64 + minimum: 0 + description: What is left on the freelist after this call. + + ScheduledTask: + type: object + required: [name, title, cron, enabled, configured, catch_up, next_runs, last_run_at] + properties: + name: + type: string + description: | + Stable identifier, used as the path parameter. It outlives renames + of whatever implements the task. + title: { type: string } + description: { type: string } + cron: + type: string + description: | + Standard five-field crontab expression, in the server's local + timezone. `@daily` and friends are accepted. + enabled: { type: boolean } + configured: + type: boolean + description: | + False while the task is still on its built-in or environment + default, i.e. nobody has chosen this schedule. + catch_up: + type: boolean + description: | + Whether a slot missed while the server was down runs late. False + follows crontab exactly; true is reserved for tasks cheap enough + that skipping them entirely would be worse. + next_runs: + type: array + description: | + The next few runs, computed server-side by the parser that fires + them. Empty when the task is disabled. + items: { type: string, format: date-time } + last_run_at: { type: string, format: date-time, nullable: true } + last_status: + type: string + enum: [running, ok, failed, interrupted] + description: | + `interrupted` is a run the server did not survive — the status is + written before the handler and cleared after it, so anything still + marked running at startup belongs to a process that did not return. + last_error: { type: string } + last_millis: { type: integer, format: int64 } + updated_by: + type: string + description: Who last saved this schedule. Absent while it is still on a default. + running: + type: boolean + description: True while the task's handler is executing. + + ScheduleUpdate: + type: object + description: Absent fields are left unchanged. + properties: + cron: { type: string } + enabled: { type: boolean } + + EmbeddingProviderList: type: object required: [providers] @@ -4028,7 +4998,9 @@ components: chroma_path: type: string nullable: true - description: Resolved chromem-go collection directory for this project. NULL when not computed. + description: | + Path of the SQLite vector database holding this project's + collection. NULL when not computed. sqlite_size_bytes: type: integer format: int64 diff --git a/docker-compose.cuda.yml b/docker-compose.cuda.yml index e0b119e0..95f888ae 100644 --- a/docker-compose.cuda.yml +++ b/docker-compose.cuda.yml @@ -17,7 +17,19 @@ services: # if a third-party fork or custom build sets a different default. - CIX_PORT=${CIX_PORT:-21847} - CIX_EMBEDDING_MODEL=${CIX_EMBEDDING_MODEL:-awhiteside/CodeRankEmbed-Q8_0-GGUF} + # Legacy chromem-go store: read once on startup for the one-time import + # into the SQLite vector store, then left untouched as the rollback path. - CIX_CHROMA_PERSIST_DIR=/data/chroma + # Optional. Vector store: one SQLite database per embedding namespace. + # Defaults to a sibling of CIX_CHROMA_PERSIST_DIR (/data/vectors), so it + # already lands on the volume below — set it only to move the vectors to + # a different mount. + # - CIX_VECTORS_DIR=/data/vectors + # Optional. PRAGMA mmap_size for the vector store, in bytes; 0 (default) + # is off. Roughly 40% lower search latency in exchange for resident + # memory — mapped database pages count in RSS, per connection. There is + # room for it under the 10G limit below on a GPU host. + # - CIX_VECTOR_MMAP_SIZE=2147483648 - CIX_SQLITE_PATH=/data/sqlite/projects.db - CIX_MAX_FILE_SIZE=${CIX_MAX_FILE_SIZE:-524288} - CIX_EXCLUDED_DIRS=${CIX_EXCLUDED_DIRS:-node_modules,.git,.venv,__pycache__,dist,build,.next,.cache,.DS_Store} @@ -98,11 +110,19 @@ services: - driver: nvidia count: 1 capabilities: [gpu] + # /health only answers once the whole boot is done — llama model load + # (minutes from cold) and, on the first start after an upgrade, the + # one-time chromem -> SQLite vector-store import. Failures inside + # start_period do not count, so this window is simply "how long before a + # slow boot may be called broken". Nothing acts on `unhealthy` here + # (restart: unless-stopped reacts to exits, not health), so an over-long + # window costs nothing, while a short one paints the container red mid-boot + # and invites a restart that throws the boot away. healthcheck: test: ["CMD", "/cix-server", "-healthcheck"] interval: 30s timeout: 10s - start_period: 120s + start_period: 600s retries: 3 volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 279c1573..62f450d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,19 @@ services: # default. - CIX_PORT=${CIX_PORT:-21847} - CIX_EMBEDDING_MODEL=${CIX_EMBEDDING_MODEL:-awhiteside/CodeRankEmbed-Q8_0-GGUF} + # Legacy chromem-go store: read once on startup for the one-time import + # into the SQLite vector store, then left untouched as the rollback path. - CIX_CHROMA_PERSIST_DIR=/data/chroma + # Optional. Vector store: one SQLite database per embedding namespace. + # Defaults to a sibling of CIX_CHROMA_PERSIST_DIR (/data/vectors), so it + # already lands on the volume below — set it only to move the vectors to + # a different mount. + # - CIX_VECTORS_DIR=/data/vectors + # Optional. PRAGMA mmap_size for the vector store, in bytes; 0 (default) + # is off. Roughly 40% lower search latency in exchange for resident + # memory — mapped database pages count in RSS, per connection. Leave it + # off under the 2G limit below. + # - CIX_VECTOR_MMAP_SIZE=2147483648 - CIX_SQLITE_PATH=/data/sqlite/projects.db - CIX_MAX_FILE_SIZE=${CIX_MAX_FILE_SIZE:-524288} - CIX_EXCLUDED_DIRS=${CIX_EXCLUDED_DIRS:-node_modules,.git,.venv,__pycache__,dist,build,.next,.cache,.DS_Store} @@ -95,11 +107,19 @@ services: cpus: "${CPUS:-2.0}" reservations: memory: 1G + # /health only answers once the whole boot is done — llama model load + # (minutes from cold) and, on the first start after an upgrade, the + # one-time chromem -> SQLite vector-store import. Failures inside + # start_period do not count, so this window is simply "how long before a + # slow boot may be called broken". Nothing acts on `unhealthy` here + # (restart: unless-stopped reacts to exits, not health), so an over-long + # window costs nothing, while a short one paints the container red mid-boot + # and invites a restart that throws the boot away. healthcheck: test: ["CMD", "/cix-server", "-healthcheck"] interval: 30s timeout: 10s - start_period: 120s + start_period: 600s retries: 3 volumes: diff --git a/portainer-stack-cuda.yml b/portainer-stack-cuda.yml index 4a55997f..61c8f2a6 100644 --- a/portainer-stack-cuda.yml +++ b/portainer-stack-cuda.yml @@ -8,7 +8,18 @@ services: environment: - CIX_API_KEY=${API_KEY} - CIX_EMBEDDING_MODEL=${EMBEDDING_MODEL:-awhiteside/CodeRankEmbed-Q8_0-GGUF} + # Legacy chromem-go store: read once on startup for the one-time import + # into the SQLite vector store, then left untouched as the rollback path. - CIX_CHROMA_PERSIST_DIR=/data/chroma + # Optional. Vector store: one SQLite database per embedding namespace. + # Defaults to a sibling of CIX_CHROMA_PERSIST_DIR (/data/vectors), so it + # already lands on the data volume — set it only to move the vectors to + # a different mount. + # - CIX_VECTORS_DIR=/data/vectors + # Optional. PRAGMA mmap_size for the vector store, in bytes; 0 (default) + # is off. Roughly 40% lower search latency in exchange for resident + # memory — mapped database pages count in RSS, per connection. + # - CIX_VECTOR_MMAP_SIZE=2147483648 - CIX_SQLITE_PATH=/data/sqlite/projects.db - CIX_MAX_FILE_SIZE=${MAX_FILE_SIZE:-524288} - CIX_EXCLUDED_DIRS=${EXCLUDED_DIRS:-node_modules,.git,.venv,__pycache__,dist,build,.next,.cache,.DS_Store} @@ -28,11 +39,19 @@ services: - driver: nvidia count: 1 capabilities: [gpu] + # /health only answers once the whole boot is done — llama model load + # (minutes from cold) and, on the first start after an upgrade, the + # one-time chromem -> SQLite vector-store import. Failures inside + # start_period do not count, so this window is simply "how long before a + # slow boot may be called broken". Nothing acts on `unhealthy` here + # (restart: unless-stopped reacts to exits, not health), so an over-long + # window costs nothing, while a short one paints the container red mid-boot + # and invites a restart that throws the boot away. healthcheck: test: ["/cix-server", "-healthcheck"] interval: 30s timeout: 10s - start_period: 120s + start_period: 600s retries: 3 volumes: diff --git a/portainer-stack.yml b/portainer-stack.yml index 94070de0..440508af 100644 --- a/portainer-stack.yml +++ b/portainer-stack.yml @@ -8,7 +8,18 @@ services: environment: - CIX_API_KEY=${API_KEY} - CIX_EMBEDDING_MODEL=${EMBEDDING_MODEL:-awhiteside/CodeRankEmbed-Q8_0-GGUF} + # Legacy chromem-go store: read once on startup for the one-time import + # into the SQLite vector store, then left untouched as the rollback path. - CIX_CHROMA_PERSIST_DIR=/data/chroma + # Optional. Vector store: one SQLite database per embedding namespace. + # Defaults to a sibling of CIX_CHROMA_PERSIST_DIR (/data/vectors), so it + # already lands on the data volume — set it only to move the vectors to + # a different mount. + # - CIX_VECTORS_DIR=/data/vectors + # Optional. PRAGMA mmap_size for the vector store, in bytes; 0 (default) + # is off. Roughly 40% lower search latency in exchange for resident + # memory — mapped database pages count in RSS, per connection. + # - CIX_VECTOR_MMAP_SIZE=2147483648 - CIX_SQLITE_PATH=/data/sqlite/projects.db - CIX_MAX_FILE_SIZE=${MAX_FILE_SIZE:-524288} - CIX_EXCLUDED_DIRS=${EXCLUDED_DIRS:-node_modules,.git,.venv,__pycache__,dist,build,.next,.cache,.DS_Store} @@ -25,11 +36,19 @@ services: cpus: "${CPUS:-2.0}" reservations: memory: 1G + # /health only answers once the whole boot is done — llama model load + # (minutes from cold) and, on the first start after an upgrade, the + # one-time chromem -> SQLite vector-store import. Failures inside + # start_period do not count, so this window is simply "how long before a + # slow boot may be called broken". Nothing acts on `unhealthy` here + # (restart: unless-stopped reacts to exits, not health), so an over-long + # window costs nothing, while a short one paints the container red mid-boot + # and invites a restart that throws the boot away. healthcheck: test: ["/cix-server", "-healthcheck"] interval: 30s timeout: 10s - start_period: 120s + start_period: 600s retries: 3 volumes: diff --git a/server/Dockerfile.cuda b/server/Dockerfile.cuda index 07eb4221..81b23a04 100644 --- a/server/Dockerfile.cuda +++ b/server/Dockerfile.cuda @@ -193,7 +193,15 @@ ENV CIX_LLAMA_TRANSPORT=unix EXPOSE 21847 VOLUME ["/data"] -HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \ +# The image default, used when nobody overrides it in compose. /health only +# answers once the whole boot is done — llama model load (minutes from cold) +# and, on the first start after an upgrade, the one-time chromem -> SQLite +# vector-store import — so the start period is generous on purpose. Failures +# inside it do not count toward --retries, and marking a container unhealthy +# triggers nothing by itself, so the only cost of a long window is a later +# label; the cost of a short one is a red container mid-boot and an operator +# restarting a server that was merely still starting. +HEALTHCHECK --interval=30s --timeout=10s --start-period=600s --retries=3 \ CMD ["/cix-server", "-healthcheck"] # Run as numeric uid/gid 1001 to match the previous Ubuntu-based image's diff --git a/server/Makefile b/server/Makefile index 96a4a879..2f8d4a63 100644 --- a/server/Makefile +++ b/server/Makefile @@ -8,6 +8,11 @@ LLAMA_VERSION ?= b10238 LLAMA_REPO ?= ggml-org/llama.cpp +# Where fetch-llama.sh keeps checksum-verified release archives. Outside the +# repo on purpose: it survives `make clean` and a fresh clone, which is what +# makes a version bump (or a re-clone) cost no network. +LLAMA_CACHE_DIR ?= $(if $(XDG_CACHE_HOME),$(XDG_CACHE_HOME),$(HOME)/.cache)/cix/llama + # `make bundle OS=... ARCH=...` supports only darwin-arm64 in Phase 3. OS ?= darwin ARCH ?= arm64 @@ -93,6 +98,13 @@ help: @echo " dashboard-dev — run vite dev server on :5173 (proxies /api to :21847)" @echo " dashboard-clean — remove built dashboard artefacts (keeps PLACEHOLDER.html)" @echo " clean — remove dist/" + @echo "" + @echo "build / bundle / run skip any step that would reproduce identical output." + @echo "Force one:" + @echo " LLAMA_FORCE=1 — refetch llama.cpp, restage dist/llama/, rebuild the bundle" + @echo " BUNDLE_FORCE=1 — re-copy + re-sign the bundle's llama/ only" + @echo " DASHBOARD_FORCE=1 — rebuild the React dashboard" + @echo "Verified llama.cpp archives are cached in $(LLAMA_CACHE_DIR) (override LLAMA_CACHE_DIR)." # `go build` embeds internal/httpapi/dashboard/dist/ via go:embed — the # dashboard must be built first so the binary picks up fresh assets. @@ -118,6 +130,8 @@ fetch-llama: LLAMA_OS=$(OS) \ LLAMA_ARCH=$(ARCH) \ LLAMA_STRICT=$(LLAMA_STRICT) \ + LLAMA_FORCE=$(LLAMA_FORCE) \ + LLAMA_CACHE_DIR=$(LLAMA_CACHE_DIR) \ DEST_DIR=$(LLAMA_DIR) \ CHECKSUMS_FILE=$(ROOT)/scripts/llama-checksums.txt \ $(ROOT)/scripts/fetch-llama.sh @@ -131,19 +145,40 @@ bundle: fetch-llama build @# up by DYLD") — but the bundle is the directory that actually ships and @# runs. A b8914 bundle upgraded in place kept 9 stale dylibs (13 MB), @# including libllama.0.0.8914.dylib, sitting next to the b10238 set. - rm -rf $(BUNDLE_DIR)/llama - mkdir -p $(BUNDLE_DIR)/llama - cp -R $(LLAMA_DIR)/. $(BUNDLE_DIR)/llama/ -ifeq ($(OS),darwin) - @# macOS Sequoia (26+) tightened amfid: ad-hoc-signed binaries whose - @# linked dylibs carry stale signatures or a com.apple.provenance - @# xattr from the previous bundle get SIGKILL'd within milliseconds - @# of execve — supervisor sees "signal: killed" with empty stderr. - @# `cp -R` creates new files macOS treats as untrusted, so the strip - @# + deep re-sign must run on every bundle, not just first install. - @xattr -cr $(BUNDLE_DIR)/llama/ - @codesign --force --deep --sign - $(BUNDLE_DIR)/llama/llama-server -endif + @# + @# …and skip the whole thing when the bundle already holds the exact + @# version staged in dist/llama. `make run` goes through here on every + @# server restart; copying 52 MB and re-signing it to produce a + @# byte-identical tree is pure latency. The comparison is on the + @# .llama-version stamp fetch-llama.sh writes last, so a bundle assembled + @# from an interrupted fetch has no stamp and is always rebuilt. + @# BUNDLE_FORCE=1 rebuilds regardless. + @# + @# When the copy DOES run on macOS, the xattr strip + deep re-sign run with + @# it, every time. Sequoia (26+) tightened amfid: ad-hoc-signed binaries + @# whose linked dylibs carry stale signatures or a com.apple.provenance + @# xattr from the previous bundle get SIGKILL'd within milliseconds of + @# execve — the supervisor sees "signal: killed" with empty stderr. `cp -R` + @# creates new files macOS treats as untrusted, so re-signing is tied to + @# the copy and must never be skipped independently of it. + @# LLAMA_FORCE implies BUNDLE_FORCE. A forced refetch means the operator + @# distrusts what is in dist/llama; re-staging it and then leaving the + @# bundle on its old copy — which the stamp comparison would happily do, + @# the version being unchanged — would defeat the point of asking. + @if [ "$(BUNDLE_FORCE)" != "1" ] && [ "$(LLAMA_FORCE)" != "1" ] \ + && [ -x "$(BUNDLE_DIR)/llama/llama-server" ] \ + && [ -f "$(LLAMA_DIR)/.llama-version" ] \ + && cmp -s "$(BUNDLE_DIR)/llama/.llama-version" "$(LLAMA_DIR)/.llama-version"; then \ + echo "→ bundle llama/ already at $$(cat $(LLAMA_DIR)/.llama-version) — skipping copy + codesign"; \ + else \ + rm -rf $(BUNDLE_DIR)/llama; \ + mkdir -p $(BUNDLE_DIR)/llama; \ + cp -R $(LLAMA_DIR)/. $(BUNDLE_DIR)/llama/; \ + if [ "$(OS)" = "darwin" ]; then \ + xattr -cr $(BUNDLE_DIR)/llama/; \ + codesign --force --deep --sign - $(BUNDLE_DIR)/llama/llama-server; \ + fi; \ + fi @echo "Bundle ready: $(BUNDLE_DIR)" @echo "Optional: tar czf $(DIST_DIR)/$(BUNDLE_NAME).tar.gz -C $(DIST_DIR) $(BUNDLE_NAME)" @@ -334,12 +369,34 @@ dashboard-gen-types: # day-to-day rebuilds where node_modules already exists this is wasteful; # fall through to a no-op when node_modules is present and the lockfile # hasn't changed since the last install. +# +# The build itself is content-addressed against DASHBOARD_STAMP: `make run` +# pulls this target in on every server restart, and re-running tsc + vite to +# emit the same bundle is the single biggest chunk of a no-op rebuild. The +# stamp records the digest of every build input (see the script for what +# counts) and is written only after a build succeeds, so an interrupted or +# failing build never leaves a stamp that would suppress the retry. +# +# Escape hatches: DASHBOARD_FORCE=1 to rebuild anyway; `make dashboard-clean` +# drops the stamp along with the output. +DASHBOARD_STAMP := $(DASHBOARD_DIR)/.build-stamp + dashboard-build: @if [ ! -d "$(DASHBOARD_DIR)/node_modules" ]; then \ echo "→ npm ci (no node_modules/)"; \ cd $(DASHBOARD_DIR) && npm ci; \ fi - cd $(DASHBOARD_DIR) && npm run gen:api && npm run build + @want=$$($(ROOT)/scripts/dashboard-inputs-hash.sh "$(DASHBOARD_DIR)" "$(ROOT)/../doc/openapi.yaml"); \ + if [ "$(DASHBOARD_FORCE)" != "1" ] \ + && [ -f "$(DASHBOARD_OUT)/index.html" ] \ + && [ -f "$(DASHBOARD_STAMP)" ] \ + && [ "$$(cat $(DASHBOARD_STAMP))" = "$$want" ]; then \ + echo "→ dashboard unchanged ($${want}) — skipping build (DASHBOARD_FORCE=1 to rebuild)"; \ + else \ + rm -f "$(DASHBOARD_STAMP)"; \ + (cd $(DASHBOARD_DIR) && npm run gen:api && npm run build) || exit 1; \ + printf '%s\n' "$$want" > "$(DASHBOARD_STAMP)"; \ + fi dashboard-dev: dashboard-gen-types cd $(DASHBOARD_DIR) && npm run dev @@ -352,3 +409,7 @@ dashboard-clean: find $(DASHBOARD_OUT) -mindepth 1 ! -name .gitkeep -exec rm -rf {} +; \ echo "→ Cleaned $(DASHBOARD_OUT) (kept .gitkeep)"; \ fi + @# The stamp lives outside dist/ (it must not end up inside the embed FS), + @# so it has to be dropped explicitly — otherwise the next build sees a + @# matching digest and skips, leaving dist/ empty. + @rm -f "$(DASHBOARD_STAMP)" diff --git a/server/cmd/cix-server/main.go b/server/cmd/cix-server/main.go index ef808fc5..a2b8e7c6 100644 --- a/server/cmd/cix-server/main.go +++ b/server/cmd/cix-server/main.go @@ -16,6 +16,7 @@ import ( "os/signal" "strconv" "strings" + "sync" "syscall" "time" @@ -24,6 +25,7 @@ import ( "github.com/dvcdsys/code-index/server/internal/chunker/tswasm" "github.com/dvcdsys/code-index/server/internal/config" "github.com/dvcdsys/code-index/server/internal/db" + "github.com/dvcdsys/code-index/server/internal/dbmaint" "github.com/dvcdsys/code-index/server/internal/embeddings" "github.com/dvcdsys/code-index/server/internal/embeddings/provider" "github.com/dvcdsys/code-index/server/internal/embeddingscfg" @@ -38,6 +40,7 @@ import ( "github.com/dvcdsys/code-index/server/internal/repojobs" "github.com/dvcdsys/code-index/server/internal/repolocks" "github.com/dvcdsys/code-index/server/internal/runtimecfg" + "github.com/dvcdsys/code-index/server/internal/schedule" "github.com/dvcdsys/code-index/server/internal/secrets" "github.com/dvcdsys/code-index/server/internal/sessions" "github.com/dvcdsys/code-index/server/internal/storage" @@ -87,10 +90,47 @@ func main() { } return } - if err := run(); err != nil { + restart, err := run() + if err != nil { fmt.Fprintln(os.Stderr, "cix-server:", err) os.Exit(1) } + if restart { + reexec() + } +} + +// reexec replaces this process image with a fresh one, which is how a +// compacted database is adopted: the swap happens at boot, before anything +// opens the file, and the only way to get there is to start over. +// +// It must run from main(), after run() has returned and every deferred +// cleanup has unwound. That ordering is not stylistic. The listener has to be +// closed or the new image fails to bind; the embeddings sidecar has to be +// reaped or the new image collides with it on its port and leaves a zombie it +// has no handle for; the tunnel binary is the same. syscall.Exec replaces the +// image outright, so no deferred function runs after it — everything that +// needs to happen must already have happened. +// +// Exec rather than exit: the process keeps its pid, so a container keeps its +// PID 1 and no restart policy or supervisor is involved. +func reexec() { + bin, err := os.Executable() + if err != nil { + bin = os.Args[0] + } + // The one-shot flags (-v, -healthcheck, -reset-password) all return from + // main before run() is reached, so a process that got here provably was + // not started with one and re-running this argv cannot land in one. + if err := syscall.Exec(bin, os.Args, os.Environ()); err != nil { + // Exec only returns on failure, and by now the listener, the database + // and both sidecars are gone. There is nothing left to continue with. + // The compacted copy and its journal are on disk, so whatever starts + // this server next completes the swap. + fmt.Fprintln(os.Stderr, "cix-server: could not restart to adopt the compacted database:", err) + fmt.Fprintln(os.Stderr, "cix-server: start the server again to finish the operation") + os.Exit(1) + } } // parseLogLevel maps CIX_LOG_LEVEL (debug|info|warn|error, case-insensitive) @@ -119,16 +159,19 @@ func parseLogLevel(s string) slog.Level { } } -func run() error { +// run boots the server and blocks until it is asked to stop. The bool reports +// whether the process should re-execute itself rather than exit — the way a +// compacted database is adopted. +func run() (restart bool, err error) { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLogLevel(os.Getenv("CIX_LOG_LEVEL"))})) slog.SetDefault(logger) cfg, err := config.Load() if err != nil { - return fmt.Errorf("load config: %w", err) + return false, fmt.Errorf("load config: %w", err) } if err := cfg.Validate(); err != nil { - return fmt.Errorf("validate config: %w", err) + return false, fmt.Errorf("validate config: %w", err) } // CIX_AUTH_DISABLED=true skips ALL auth — log loudly so the warning @@ -164,16 +207,26 @@ func run() error { // LEGACY-MIGRATION (remove next release): drop this adoption call once // all deployments have booted on the unified layout. if err := storage.AdoptLegacyModelDB(cfg.SQLitePath, cfg.LegacyDynamicSQLitePath(), logger); err != nil { - return fmt.Errorf("adopt legacy system db: %w", err) + return false, fmt.Errorf("adopt legacy system db: %w", err) } dbPath := cfg.SQLitePath + + // Adopt a compacted copy left by a previous run, or undo a swap that was + // interrupted. This has to happen here — after the legacy adoption above, + // before anything opens the file — because it renames the database, and a + // rename under an open handle is exactly the corruption this feature + // exists to avoid. + if err := dbmaint.Reconcile(context.Background(), dbPath, logger); err != nil { + return false, fmt.Errorf("reconcile database maintenance state: %w", err) + } + logger.Info("opening database", "path", dbPath) database, err := db.OpenWith(db.OpenOptions{ Path: dbPath, DataDir: cfg.WorkspacesDataDir, }) if err != nil { - return fmt.Errorf("open db: %w", err) + return false, fmt.Errorf("open db: %w", err) } defer func() { if err := database.Close(); err != nil { @@ -194,7 +247,7 @@ func run() error { rcfg := runtimecfg.New(database, cfg) snap, err := rcfg.Get(context.Background()) if err != nil { - return fmt.Errorf("load runtime_settings: %w", err) + return false, fmt.Errorf("load runtime_settings: %w", err) } snap.ApplyTo(cfg) // Apply the chunker instance-concurrency cap from the resolved snapshot @@ -232,13 +285,13 @@ func run() error { persistedProv, hasProv, err := embedCfgStore.Get(context.Background()) if err != nil { startupCancel() - return fmt.Errorf("load embedding provider config: %w", err) + return false, fmt.Errorf("load embedding provider config: %w", err) } if !hasProv && cfg.EmbeddingsEnabled { seed, serr := embeddings.BuildOllamaConfigFromEnv(cfg) if serr != nil { startupCancel() - return fmt.Errorf("seed embedding provider config: %w", serr) + return false, fmt.Errorf("seed embedding provider config: %w", serr) } if serr := embedCfgStore.Save(context.Background(), embeddingscfg.Snapshot{Kind: "ollama", Config: seed}, ""); serr != nil { @@ -296,7 +349,7 @@ func run() error { } startupCancel() if err != nil { - return fmt.Errorf("embeddings: %w", err) + return false, fmt.Errorf("embeddings: %w", err) } // Shared shutdown context — see M7 below. We build it lazily (in the // signal handler) so startup doesn't carry a dangling deadline. @@ -318,7 +371,7 @@ func run() error { // Relocate a legacy Python ChromaDB store occupying the container path // itself, so chromaBase is free to become the nested container below. if backed, bErr := vectorstore.DetectLegacyAndBackup(cfg.ChromaPersistDir); bErr != nil { - return fmt.Errorf("back up legacy python chroma store: %w", bErr) + return false, fmt.Errorf("back up legacy python chroma store: %w", bErr) } else if backed { logger.Warn("legacy python chroma layout detected at container path — backed up; re-run cix init to reindex") } @@ -336,7 +389,7 @@ func run() error { // un-migrated legacy dir — search would silently return nothing on // a "healthy" server. Surface it so the operator fixes the cause // (e.g. dir perms under prod uid 1001) rather than losing the index. - return fmt.Errorf("migrate legacy chroma dirs: %w", err) + return false, fmt.Errorf("migrate legacy chroma dirs: %w", err) } // The vector store is namespaced by the ACTIVE provider's identity path @@ -348,11 +401,23 @@ func run() error { // shaped fallback so toggling embeddings on/off doesn't move dirs. components = []string{provider.KindOllama, provider.StorageSlug(cfg.EmbeddingModel)} } - chromaDir := cfg.ChromaDirFor(components) - - vs, err := vectorstore.Open(chromaDir) + // openVectorStore opens the SQLite vector store for an embedding identity, + // pointing it at the chromem directory of the SAME identity. Opening is + // where a one-time import of the legacy gob files happens (once per + // namespace, recorded in the database, resumable); on a fresh install + // there is nothing to import and it is a file open. + openVectorStore := func(comps []string) (*vectorstore.Store, error) { + return vectorstore.OpenWith(vectorstore.Options{ + Dir: cfg.VectorDirFor(comps), + LegacyChromaDir: cfg.ChromaDirFor(comps), + MMapBytes: cfg.VectorMMapSize, + Logger: logger, + }) + } + + vs, err := openVectorStore(components) if err != nil { - return fmt.Errorf("open vectorstore: %w", err) + return false, fmt.Errorf("open vectorstore: %w", err) } // Wrap in a swappable Holder shared by indexer / repojobs / httpapi so // a runtime provider switch can reopen the store under a new namespace. @@ -360,10 +425,15 @@ func run() error { // Wire the live-reopen path used by SwitchProvider. embedSvc.AttachVectorStore( vsHolder, - cfg.ChromaDirFor, - vectorstore.Open, + openVectorStore, func() error { return storage.MigrateFlatChromaToNested(cfg.ChromaPersistDir, logger) }, ) + // The store owns an open SQLite pool now, so it is closed on the way out. + defer func() { + if err := vsHolder.Close(); err != nil { + logger.Error("vector store close", "err", err) + } + }() idx := indexer.New(database, vsHolder, embedSvc, logger) idx.SetEmbedIncludePath(cfg.EmbedIncludePath) @@ -402,7 +472,7 @@ func run() error { if !cfg.AuthDisabled { if err := bootstrapAuth(context.Background(), cfg, logger, usrSvc, akSvc); err != nil { - return fmt.Errorf("bootstrap auth: %w", err) + return false, fmt.Errorf("bootstrap auth: %w", err) } } @@ -417,7 +487,7 @@ func run() error { AllowGenerate: true, }) if err != nil { - return fmt.Errorf("secrets: %w", err) + return false, fmt.Errorf("secrets: %w", err) } ghSvc := githubtokens.New(database, secSvc) @@ -427,13 +497,13 @@ func run() error { // on a wrong key. n, err := ghSvc.CountWithEncryption(context.Background()) if err != nil { - return fmt.Errorf("secrets sanity: %w", err) + return false, fmt.Errorf("secrets sanity: %w", err) } if n > 0 { toks, _ := ghSvc.List(context.Background()) if len(toks) > 0 { if _, err := ghSvc.Reveal(context.Background(), toks[0].ID); err != nil { - return fmt.Errorf("encryption key does not match existing github_tokens — refusing to start (recover the prior CIX_SECRET_KEY or wipe github_tokens manually): %w", err) + return false, fmt.Errorf("encryption key does not match existing github_tokens — refusing to start (recover the prior CIX_SECRET_KEY or wipe github_tokens manually): %w", err) } } } @@ -602,8 +672,71 @@ func run() error { ) go pollSvc.Run(bgCtx) + // Database compaction plumbing. The gate is the write freeze the router + // consults; restartCh is how the compactor asks run() to unwind and + // re-execute, which is the only way a compacted copy gets adopted. + dbGate := &dbmaint.Gate{} + restartCh := make(chan struct{}) + var restartOnce sync.Once + + // Quiesce drains every background writer, in dependency order: the + // producers first (they enqueue work), then the queue itself. Cancelling + // is not enough for any of them — the compactor needs to know they have + // *finished*, because a tick still in flight would write into a database + // that is about to be replaced. + var ( + dbMaintSvc *dbmaint.Service + schedReg = schedule.New(database, logger) + ) + quiesce := func(ctx context.Context) error { + bgCancel() + if err := pollSvc.Stop(ctx); err != nil { + return fmt.Errorf("poll scheduler did not stop: %w", err) + } + if err := schedReg.Stop(ctx); err != nil { + return fmt.Errorf("the recurring-task scheduler did not stop: %w", err) + } + jobsCancel() + if err := jobsSvc.Stop(ctx); err != nil { + return fmt.Errorf("job queue did not drain: %w", err) + } + return nil + } + + dbMaintSvc = dbmaint.New(dbmaint.Deps{ + DB: database, + DBPath: cfg.SQLitePath, + Logger: logger, + Quiesce: quiesce, + Freeze: dbGate.Freeze, + Thaw: dbGate.Thaw, + RequestRestart: func() { restartOnce.Do(func() { close(restartCh) }) }, + ActiveJobs: dbmaint.ActiveWorkCounter(database, idx.ActiveSessions), + MinFreePercent: cfg.DBMaintenanceMinFreePercent, + MinFreeBytes: cfg.DBMaintenanceMinFreeBytes, + }) + for _, t := range dbMaintSvc.Tasks() { + // The environment supplies a default for the *reclaim* schedule only. + // One variable feeding both would mean an operator who set a nightly + // reclaim time gets a full rebuild — freeze plus restart — at that same + // hour the moment anybody switches compaction on. + var envCron *string + if t.Name == dbmaint.TaskReclaim { + envCron = cfg.DBMaintenanceCron + } + schedReg.Register(t, envCron) + } + go schedReg.Run(bgCtx) + handler := httpapi.NewRouter(httpapi.Deps{ + DBMaint: httpapi.DBMaintHooks{ + Service: dbMaintSvc, + Gate: dbGate, + RequestRestart: func() { restartOnce.Do(func() { close(restartCh) }) }, + Quiesce: quiesce, + }, DB: database, + Schedules: schedReg, ServerVersion: version, APIVersion: apiVersion, Backend: backend, @@ -626,6 +759,7 @@ func run() error { WorkspaceProjects: wpSvc, Jobs: jobsSvc, DataDir: cfg.WorkspacesDataDir, + Cfg: cfg, RepoLocks: repoLocks, PublicBaseURL: cfg.PublicBaseURL, Tunnel: tunnelMgr, @@ -680,11 +814,14 @@ func run() error { select { case sig := <-stop: logger.Info("shutdown signal received", "signal", sig.String()) + case <-restartCh: + restart = true + logger.Info("restarting to adopt the compacted database") case err := <-serverErr: if err != nil { - return fmt.Errorf("server: %w", err) + return false, fmt.Errorf("server: %w", err) } - return nil + return false, nil } // M7 — single shared shutdown budget for HTTP drain + embeddings supervisor. @@ -694,8 +831,22 @@ func run() error { shutdownCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { - return fmt.Errorf("graceful shutdown: %w", err) + if restart { + // A compacted copy is verified and journalled, and nothing brings + // this process back if it exits here — re-executing *is* the + // restart mechanism, there is no supervisor behind it. So one slow + // in-flight request outliving the drain budget must not be allowed + // to take the server down with the swap left undone. Force the + // listener closed instead, or the new image cannot bind. + logger.Error("graceful shutdown timed out; restarting anyway to adopt the compacted database", + "err", err) + if cerr := srv.Close(); cerr != nil { + logger.Warn("could not force the listener closed", "err", cerr) + } + return true, nil + } + return false, fmt.Errorf("graceful shutdown: %w", err) } logger.Info("server stopped") - return nil + return restart, nil } diff --git a/server/cmd/cix-server/resetpassword.go b/server/cmd/cix-server/resetpassword.go index 3d76ba8c..60936d99 100644 --- a/server/cmd/cix-server/resetpassword.go +++ b/server/cmd/cix-server/resetpassword.go @@ -8,11 +8,13 @@ import ( "errors" "fmt" "io" + "log/slog" "os" "strings" "github.com/dvcdsys/code-index/server/internal/config" "github.com/dvcdsys/code-index/server/internal/db" + "github.com/dvcdsys/code-index/server/internal/dbmaint" "github.com/dvcdsys/code-index/server/internal/sessions" "github.com/dvcdsys/code-index/server/internal/users" ) @@ -34,6 +36,15 @@ func runResetPassword(email string) error { return fmt.Errorf("load config: %w", err) } + // Settle any interrupted compaction first. Without this, a database + // caught mid-swap is either missing (the stat below would report "not + // found" at a path that is perfectly correct) or is the pre-compaction + // original that the next server start would replace — so the reset would + // land in a file about to be discarded. + if err := dbmaint.Reconcile(context.Background(), cfg.SQLitePath, slog.Default()); err != nil { + return fmt.Errorf("reconcile database maintenance state: %w", err) + } + // Opening a missing path would CREATE an empty DB and "successfully" // find no users in it — worse than useless for someone trying to // recover access. Refuse instead and point at the knob. diff --git a/server/cmd/seedfixture/main.go b/server/cmd/seedfixture/main.go new file mode 100644 index 00000000..9d96dcc4 --- /dev/null +++ b/server/cmd/seedfixture/main.go @@ -0,0 +1,86 @@ +//go:build ignore + +// seedfixture writes a small, self-contained cix data directory for manually +// exercising the admin Resources screen: a few real vector collections, some +// cloned-checkout directories, and nothing else. Run it, point a server at the +// directory, then register only SOME of the projects so the rest show up as +// reclaimable. +// +// Build-tagged `ignore` so it never joins the module build; run it with +// `go run cmd/seedfixture/main.go `. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/dvcdsys/code-index/server/internal/projects" + "github.com/dvcdsys/code-index/server/internal/vectorstore" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: seedfixture ") + os.Exit(2) + } + dir := os.Args[1] + + ns := filepath.Join(dir, "vectors", "ollama", "awhiteside_coderankembed_q8_0_gguf") + store, err := vectorstore.Open(ns) + check(err) + defer store.Close() + + // Three collections. Only the first is registered as a project later, so + // the other two are orphans. + paths := []string{"/live/project", "/deleted/analytics-service", "/deleted/old-prototype"} + sizes := []int{40, 900, 300} + for i, p := range paths { + chunks := make([]vectorstore.Chunk, sizes[i]) + embs := make([][]float32, sizes[i]) + for j := range chunks { + chunks[j] = vectorstore.Chunk{ + Content: fmt.Sprintf("func handler%d() error { return nil }", j), + FilePath: fmt.Sprintf("pkg/mod%d.go", j%20), + StartLine: j, EndLine: j + 8, + ChunkType: "function", SymbolName: fmt.Sprintf("handler%d", j), Language: "go", + } + embs[j] = make([]float32, 768) + embs[j][j%768] = 1 + } + check(store.UpsertChunks(context.Background(), p, chunks, embs)) + size, _ := store.CollectionSizeBytes(p) + fmt.Printf("collection %-32s %d docs, %d bytes -> %s\n", p, sizes[i], size, store.DBPath()) + } + + // Cloned checkouts: one for the live project, two for projects that are + // gone, plus a legacy UUID-named directory from an older layout. + repos := filepath.Join(dir, "repos", "repos") + for _, name := range []string{ + projects.HashPath("/live/project"), + projects.HashPath("/deleted/analytics-service"), + "3f0c1a9e2b7d4c58", + "ecc2afe1-3a8d-4fbd-946e-dc6985205d7c", + } { + d := filepath.Join(repos, name) + check(os.MkdirAll(filepath.Join(d, ".git"), 0o755)) + check(os.WriteFile(filepath.Join(d, ".git", "config"), + []byte("[remote \"origin\"]\n\turl = https://example.test/acme/"+name[:6]+".git\n"), 0o644)) + check(os.WriteFile(filepath.Join(d, "payload.bin"), make([]byte, 3<<20), 0o644)) + } + + // An abandoned namespace from a previous embedding model. + old := filepath.Join(dir, "chroma", "ollama", "legacy-embed-model", "aabbccdd") + check(os.MkdirAll(old, 0o755)) + check(os.WriteFile(filepath.Join(old, "doc.gob"), make([]byte, 5<<20), 0o644)) + + fmt.Println("seeded", dir) +} + +func check(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "seed:", err) + os.Exit(1) + } +} diff --git a/server/dashboard/.gitignore b/server/dashboard/.gitignore index ac7e93ec..7113e791 100644 --- a/server/dashboard/.gitignore +++ b/server/dashboard/.gitignore @@ -9,3 +9,11 @@ node_modules/ # Type-gen output is reproducible — never commit src/api/generated.ts + +# Digest of the last successful build's inputs — lets `make dashboard-build` +# skip a no-op rebuild. Machine-local state, meaningless in another checkout. +.build-stamp + +# Local-only visual harness (see devmock/main.tsx) — never shipped. +devmock/ +devmock.html diff --git a/server/dashboard/README.md b/server/dashboard/README.md index da33ac33..1255fec8 100644 --- a/server/dashboard/README.md +++ b/server/dashboard/README.md @@ -1,9 +1,14 @@ # cix-dashboard The embedded operator dashboard for `cix-server`. Vite + React + TypeScript + -Tailwind + shadcn/ui + TanStack Query, served by the Go binary at +Tailwind + Radix primitives + TanStack Query, served by the Go binary at `/dashboard` via `embed.FS`. +The UI follows the **cix design system** — cream & ink, described in full +under [Design](#design) below. Read that section before touching any markup; +it is the difference between a change that lands and one that has to be +redone. + ## Local development ```bash @@ -51,21 +56,24 @@ register, done**. ```ts // src/modules/projects/index.ts - import { Folder } from 'lucide-react'; import type { Module } from '../types'; import ProjectsPage from './ProjectsPage'; export const ProjectsModule: Module = { id: 'projects', label: 'Projects', - icon: Folder, path: '/projects', element: ProjectsPage, // requiredRole: 'admin', // omit for everyone, 'admin' to gate - weight: 10, // lower numbers come first in the sidebar + group: 'workspace', // or 'admin' — which sidebar block it sits in + weight: 10, // lower numbers come first within the group + blurb: 'One sentence for the home-page module grid.', }; ``` + There is no `icon` field. Nav rows carry a 7×7 square marker and the label + does the work — see the "no icon soup" rule below. + 3. **Register it**: ```ts @@ -98,14 +106,16 @@ register, done**. on success, throws an `ApiError` (with `.status` and `.detail`) on any non-2xx. The provider in `app/providers.tsx` already disables retries on 401/403. -- **UI primitives**: import from `@/ui/*` (button, card, input, dialog, - alert, sonner). All wrap shadcn-style Tailwind primitives. Add new - ones via `npx shadcn add ` when needed. -- **Icons**: `lucide-react` only. Named imports — no default re-exports - so the bundle tree-shakes. -- **Styling**: Tailwind tokens only (`bg-background`, `text-muted-foreground`, - …). Never inline `style={{ color: '#abc' }}` — colour drift is the - reason we have a token system. +- **UI primitives**: import from `@/ui/*` (button, badge, card, input, + checkbox, code, dialog, alert, page, progress, table, tabs, sonner…). + Compose those plus the `.cix-*` classes in `index.css`; reach for raw + Tailwind for layout (flex / grid / gap / spacing) and little else. +- **Icons**: there is no icon library. The chrome has exactly one mark + (`app/BrandMark.tsx`); everywhere else, state is a 9×9 square plus a word + and affordances are mono glyphs (`▼ ▾ ▸ ↗ ✕`). +- **Styling**: design tokens only — `bg-surface`, `text-dim`, `border-line-soft`, + `shadow-hard`, `rounded-card`. Never inline `style={{ color: '#abc' }}`, and + never a raw hex: colour drift is the reason the token system exists. - **Class strings**: use `cn()` from `@/lib/cn` for conditional classes. It de-duplicates conflicting Tailwind classes. - **Dates**: format via helpers in `@/lib/formatDate`. Don't sprinkle @@ -118,15 +128,18 @@ register, done**. ``` src/ main.tsx boot React + Router + providers - index.css Tailwind + shadcn CSS variables (light/dark tokens) + index.css design tokens (light/dark) + the .cix-* component layer + fonts/ self-hosted JetBrains Mono (400/500/700, no CDN) api/ client.ts fetch wrapper, ApiError, cookie-based auth types.ts stable re-exports of generated schemas generated.ts ← gitignored; produced by `npm run gen:api` app/ App.tsx auth-gate + module routing - Shell.tsx sidebar + main content layout + Shell.tsx [banner] [sidebar | main] [full-width status bar] Sidebar.tsx renders modules from the registry, role-filtered + StatusBar.tsx the bottom bar + the per-page fact context + BrandMark.tsx the one icon in the chrome providers.tsx QueryClient + AuthProvider + Toaster auth/ AuthProvider.tsx bootstrap-status + /auth/me + login/logout mutations @@ -137,8 +150,8 @@ src/ modules/ types.ts Module interface registry.ts array of all registered modules, sorted by weight - home/ PR-B placeholder home; replace/augment in PR-C - ui/ shadcn primitives — never edit unless adding a new one + home/ projects/ search/ server/ … one folder per feature + ui/ design-system primitives — the whole visual vocabulary lib/ cn.ts `cn()` className helper formatDate.ts date / relative-time helpers @@ -158,15 +171,103 @@ placeholder when `dist/index.html` is missing. ## Bundle-size budget -`npm run build` should land below ~500 KB gzipped total. Today (PR-B): +`npm run build` should land below ~500 KB gzipped total. Today: ``` -index.html 0.5 kB │ gzip: 0.3 kB -assets/index-*.css 18 kB │ gzip: 4.3 kB -assets/index-*.js 289 kB │ gzip: 90 kB +index.html 1.7 kB │ gzip: 0.8 kB +assets/index-*.css 53 kB │ gzip: 9.4 kB +assets/index-*.js 617 kB │ gzip: 186 kB +assets/jetbrains-*.woff2 104 kB (9 subset files, fetched on demand) +``` + +If a future PR pushes that significantly higher, audit imports — the usual +culprit is a Radix primitive that ships more code than the feature actually +uses. + +## Design + +Cream & ink. Five rules; everything else follows from them. + +1. **Cream, not grey.** The page is `canvas`, panels are `surface`. There is + no neutral grey — every "grey" is a warm brown (`dim`, `muted`, `faint`). +2. **Ink outlines, not shadows.** Every panel, input, button and badge is + bounded by a 1.5px ink line. Blur-based shadows do not exist. +3. **Cards are rounded (12px). Controls are square (0).** `borderRadius` is + overridden globally to `0`, so a stray `rounded-md` renders square instead + of quietly breaking the look. Only `rounded-card` rounds — and `rounded-full` + for the radio, the single circle in the system. +4. **Depth is a hard offset shadow.** `shadow-hard` (4px 4px 0) on the one + primary button per view, on the search bar, on overlays (`shadow-hard-lg`). + Hover moves the element 2px into its own shadow. Never more than one + shadowed element per screen region. +5. **Mono for every machine value** — ids, ports, paths, versions, counts, + timestamps, code, scores, key prefixes. UI sans for prose and buttons. + Numbers align right; the right edge is the reading axis. + +### The tonal ladder + +Surfaces are a measured ladder, not a set of moods — one role per step, each a +fixed CIE L\* distance from its neighbour, the same device Material 3 uses for +its `surface-container` roles and Carbon uses for layers. Light theme: + +| Role | L\* | Where | +|---|---|---| +| `field` | 97.4 | inputs, selects, the search bar | +| `surface` | 94.4 | card body, table body | +| `canvas` | 89.6 | the page | +| `surface-head` | 84.7 | card header, card footer, table header | + +Two rules fall out of it, and both were violated before it was written down: + +- **A control a user types into gets `bg-field`, never `bg-surface`.** When the + input shares its card's fill, a form reads as a grid of identical outlines + and the eye has nothing to land on; the fill is what says "type here". +- **A header strip must be one full step off the canvas.** `surface-head` used + to sit 2.0 L\* below the canvas while the body sat 4.8 above it, so card + headers blended into the page. It is now the canvas's mirror image. + +Text tones are a three-level ladder — `ink`, `dim`, `muted` — and **all three +clear WCAG AA (4.5:1) on every surface they are allowed on**. `faint` does not, +by design, and is therefore limited to non-content: placeholders, disabled +text, "never". Check with a contrast tool before introducing a fourth tone; +`muted` shipped at 3.88:1 and made every form label harder to read than it +looked in Figma. + +Rank by weight, not by count: three emphasis levels per component is the +budget. A field is *value → label → everything else*, so provenance chips and +recommendations sit at the bottom — a solid ink chip on the least important +fact is what makes a settings page look speckled. + +Corollaries worth stating because they are easy to get wrong: + +- **Red is a scalpel.** `accent` marks destructive actions, the active nav + marker, focus rings and the indexing state. Never a decorative fill. +- **No icon soup.** Status is a 9×9 square plus a word — colour never carries + meaning alone. Use ``/`` from `@/ui/badge`. +- **Disabled ≠ information.** Never render read-only facts as greyed-out + fields or menu items. They go in a key/value block (``) or a stat strip. +- **One primary action per page**, in the `` header. Sub-navigation + (tabs, segmented controls, filters) belongs in the content area. +- **The status bar spans the full window width**, under the sidebar — it is a + sibling of the sidebar+main row, never a child of `
`. Pages publish + their middle-slot fact with `useStatusFact()`. + +Colours, sizes and geometry live in `src/index.css` as RGB channel vars +(`--c-ink`, …) so the `.dark` class swaps the whole palette and Tailwind can +still apply opacity modifiers. `tailwind.config.ts` maps them to names — +`surface`, `field`, `ink`, `dim`, `muted`, `faint`, `line-soft`, `accent`, +`ok`, `warn`. + +### Reviewing a change + +`devmock/` (gitignored) is a local visual harness: it installs a mock `fetch` +and boots the real `App`, so every authenticated screen can be reviewed with +realistic data and no server or login. + +```bash +npm run dev # then open /dashboard/devmock.html ``` -If a future PR pushes that significantly higher, audit imports — the -usual culprits are accidentally pulling all of lucide-react instead of -named imports, or shadcn primitives that ship more Radix code than the -feature actually uses. +Before calling a UI change done, sweep for the four usual violations: a +`border-radius` on a control, a `box-shadow` with a blur, a raw grey hex, and +an icon used as the sole carrier of state. diff --git a/server/dashboard/index.html b/server/dashboard/index.html index b4487ed1..325cbac1 100644 --- a/server/dashboard/index.html +++ b/server/dashboard/index.html @@ -4,10 +4,12 @@ + + cix dashboard + - +
diff --git a/server/dashboard/package-lock.json b/server/dashboard/package-lock.json index 4f20a3ac..00404c5f 100644 --- a/server/dashboard/package-lock.json +++ b/server/dashboard/package-lock.json @@ -12,7 +12,6 @@ "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-radio-group": "^1.4.7", - "@radix-ui/react-scroll-area": "^1.2.18", "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slot": "^1.3.3", @@ -22,10 +21,9 @@ "@tanstack/react-query": "^5.101.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.577.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^7.18.1", + "react-router-dom": "^7.18.2", "sonner": "^1.7.4", "tailwind-merge": "^3.6.0" }, @@ -751,37 +749,6 @@ } } }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", - "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.3", - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-layout-effect": "1.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-select": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", @@ -1287,9 +1254,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1307,9 +1271,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1327,9 +1288,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1347,9 +1305,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1367,9 +1322,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1387,9 +1339,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2599,15 +2548,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lucide-react": { - "version": "0.577.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", - "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3076,9 +3016,9 @@ } }, "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3098,12 +3038,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "react-router": "7.18.1" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" diff --git a/server/dashboard/package.json b/server/dashboard/package.json index 1a0649e4..ea7c0017 100644 --- a/server/dashboard/package.json +++ b/server/dashboard/package.json @@ -17,7 +17,6 @@ "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-radio-group": "^1.4.7", - "@radix-ui/react-scroll-area": "^1.2.18", "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slot": "^1.3.3", @@ -27,10 +26,9 @@ "@tanstack/react-query": "^5.101.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.577.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^7.18.1", + "react-router-dom": "^7.18.2", "sonner": "^1.7.4", "tailwind-merge": "^3.6.0" }, diff --git a/server/dashboard/src/api/types.ts b/server/dashboard/src/api/types.ts index 8ae3c7d9..65df5eb9 100644 --- a/server/dashboard/src/api/types.ts +++ b/server/dashboard/src/api/types.ts @@ -88,3 +88,48 @@ export type TestEmbeddingProviderResponse = components['schemas']['TestEmbedding // Provider kind union — the dashboard uses this in form-state discriminants. export type EmbeddingProviderKind = 'ollama' | 'openai' | 'voyage'; + +// Admin resource accounting: what the server is using, what of that is +// reclaimable, and the result of reclaiming it. +export type ResourceUsage = components['schemas']['ResourceUsage']; +export type MemoryUsage = components['schemas']['MemoryUsage']; +export type DiskUsage = components['schemas']['DiskUsage']; +export type VectorStoreUsage = components['schemas']['VectorStoreUsage']; +export type ReclaimAnalysis = components['schemas']['ReclaimAnalysis']; +export type ReclaimCategory = components['schemas']['ReclaimCategory']; +export type ReclaimCategoryId = components['schemas']['ReclaimCategoryId']; +export type ReclaimItem = components['schemas']['ReclaimItem']; +export type CleanRequest = components['schemas']['CleanRequest']; +export type CleanResult = components['schemas']['CleanResult']; +export type CleanCategoryResult = components['schemas']['CleanCategoryResult']; + +// Database compaction: how much of the SQLite file is wasted, what an +// operation on it is doing, and when one runs automatically. +export type DatabaseState = components['schemas']['DatabaseState']; +export type AutoVacuumRequest = components['schemas']['AutoVacuumRequest']; +export type MaintenanceOperation = components['schemas']['MaintenanceOperation']; +export type MaintenanceEvent = components['schemas']['MaintenanceEvent']; +export type ReclaimRequest = components['schemas']['ReclaimRequest']; +export type ReclaimResult = components['schemas']['ReclaimResult']; +// Recurring tasks. Not database-specific — the registry is generic and the +// database's reclaim and compaction are simply its first two entries. +export type ScheduledTask = components['schemas']['ScheduledTask']; +export type ScheduleUpdate = components['schemas']['ScheduleUpdate']; + +// Phases in which something is actually happening to the database, as opposed +// to the terminal ones that only report what happened. +// +// One definition, because three components need the answer — the banner, the +// polling interval and the disabled state of the controls — and three copies +// had already started to disagree about which phases count. +const ACTIVE_PHASES: ReadonlySet = new Set([ + 'preparing', + 'copying', + 'ready_to_swap', + 'swapping', + 'restarting', +]); + +export function isActivePhase(phase: string | null | undefined): boolean { + return !!phase && ACTIVE_PHASES.has(phase); +} diff --git a/server/dashboard/src/app/App.tsx b/server/dashboard/src/app/App.tsx index b01bd9c7..5227e52b 100644 --- a/server/dashboard/src/app/App.tsx +++ b/server/dashboard/src/app/App.tsx @@ -1,29 +1,30 @@ -import { Loader2 } from 'lucide-react'; import { Navigate, Route, Routes } from 'react-router-dom'; import { useAuth } from '@/auth/useAuth'; import BootstrapNeededPage from '@/auth/BootstrapNeededPage'; import ChangePasswordPage from '@/auth/ChangePasswordPage'; import LoginPage from '@/auth/LoginPage'; -import { MODULES } from '@/modules/registry'; +import { visibleModules } from '@/modules/registry'; +import { Dots } from '@/ui/button'; import { Shell } from './Shell'; +import { StatusFactProvider } from './StatusBar'; -// Top-level auth + route gate. Three states branch off here: -// - bootstrap not done → BootstrapNeededPage (no other route works) -// - logged out → LoginPage (no Shell, no nav) -// - must change password → ChangePasswordPage (no Shell, no nav) -// - logged in & happy → Shell + module routes +// Top-level auth + route gate. Four states branch off here: +// - bootstrap not done → BootstrapNeededPage (no other route works) +// - logged out → LoginPage (no Shell, no nav) +// - must change password → ChangePasswordPage (no Shell, no nav) +// - logged in & happy → Shell + module routes // -// Module routes are derived from the registry — no manual entries -// per feature. Each module owns its `path` (relative to /dashboard) and -// renders whatever it likes inside. +// Module routes come from the registry — no manual per feature. A +// module whose role gate excludes the current user has no mounted at +// all, so a deep link to it falls through to "/". export default function App() { const { loading, needsBootstrap, user, mustChangePassword } = useAuth(); if (loading) { return ( -
- - Loading… +
+ + loading
); } @@ -48,28 +49,20 @@ export default function App() { ); } - // Authenticated + ready — render every registered module under the Shell. - // A module whose role gate excludes the current user simply has no - // mounted, so a deep link to it 404s back to /. - const visible = MODULES.filter((m) => { - if (!m.requiredRole) return true; - if (m.requiredRole === 'user') return true; - return user.role === 'admin'; - }); - return ( - - - {visible.map((m) => { - // Modules can own a sub-tree by defining their own routes inside - // their element; we mount with a trailing wildcard so they get - // them on `//*`. - const mountPath = m.path === '/' ? '/*' : `${m.path}/*`; - const Element = m.element; - return } />; - })} - } /> - - + + + + {visibleModules(user.role).map((m) => { + // Modules can own a sub-tree, so each mounts with a trailing + // wildcard and routes `//*` internally. + const mountPath = m.path === '/' ? '/*' : `${m.path}/*`; + const Element = m.element; + return } />; + })} + } /> + + + ); } diff --git a/server/dashboard/src/app/BrandMark.tsx b/server/dashboard/src/app/BrandMark.tsx new file mode 100644 index 00000000..c8f276bb --- /dev/null +++ b/server/dashboard/src/app/BrandMark.tsx @@ -0,0 +1,32 @@ +import { cn } from '@/lib/cn'; + +// The one icon in the chrome: the cix magnifier, drawn as squares and a +// straight handle so it matches the app icon and the menu-bar panel. Inline +// SVG (not a font glyph, not lucide) because it is brand, not iconography — +// currentColor drives the ring so it inverts cleanly on an ink surface. +export function BrandMark({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/server/dashboard/src/app/Footer.tsx b/server/dashboard/src/app/Footer.tsx deleted file mode 100644 index c35fe5a6..00000000 --- a/server/dashboard/src/app/Footer.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Link } from 'react-router-dom'; -import { useServerStatus } from '@/lib/useServerStatus'; -import { useAuth } from '@/auth/useAuth'; -import { cn } from '@/lib/cn'; -import { formatVersion } from '@/lib/version'; - -// Footer spans the full width below the sidebar + main pane. Reads -// from the shared /status query (polled every 30 s) — server version -// on the left, embedding-provider indicator on the right. -// -// The label is the active provider kind ("ollama" / "openai" / -// "voyage") and the dot logic depends on whether the provider -// manages an in-process child: -// ollama (manages_process=true): green when /health alive, red -// otherwise — real liveness signal. -// openai / voyage (manages_process=false): permanently green. -// We don't ping remote APIs on every footer poll; failures -// surface at search/embed time with diagnostics. -// -// The provider name links to /server (admin-only page); viewers see -// plain text since the route isn't mounted for them. -export function Footer() { - const { data, isLoading } = useServerStatus(); - const { user } = useAuth(); - const version = data?.server_version ?? 'dev'; - const providerKind = data?.embedding_provider ?? ''; - const managesProcess = data?.embedding_provider_manages_process === true; - const alive = data?.model_loaded === true; - const isAdmin = user?.role === 'admin'; - - const dotClass = isLoading - ? 'bg-muted-foreground/40' - : managesProcess - ? alive - ? 'bg-emerald-500' - : 'bg-red-500' - : 'bg-emerald-500'; - const dotTitle = isLoading - ? 'Checking embedding provider status…' - : managesProcess - ? alive - ? 'Ollama sidecar is alive' - : 'Ollama sidecar is not responding' - : providerKind - ? `${providerKind} backend (no managed process)` - : 'Embedding backend'; - - const label = providerKind || 'embeddings'; - - const indicator = ( - <> - - {label} - - ); - - return ( - - ); -} diff --git a/server/dashboard/src/app/MaintenanceBanner.tsx b/server/dashboard/src/app/MaintenanceBanner.tsx new file mode 100644 index 00000000..3bbc3a9e --- /dev/null +++ b/server/dashboard/src/app/MaintenanceBanner.tsx @@ -0,0 +1,208 @@ +import { useEffect, useRef, useState } from 'react'; +import { isActivePhase, type MaintenanceOperation } from '@/api/types'; +import { formatBytes } from '@/lib/formatBytes'; + +// How long a finished operation stays on screen. Long enough to be read after +// the restart it caused, short enough not to become furniture. +const TERMINAL_LINGER_MS = 60_000; + +// How recently an operation must have finished for its outcome to be news. +// +// The journal is never cleared — it is the only record of an operation whose +// result could not be written to the database it replaced — so without this +// every page load for the rest of the server's life would replay the same +// "database compacted" strip, and a nightly reclaim would replay it nightly. +const TERMINAL_FRESH_MS = 5 * 60_000; + +const POLL_ACTIVE_MS = 2_000; +const POLL_IDLE_MS = 30_000; + +type Status = + | { kind: 'idle' } + | { kind: 'op'; op: MaintenanceOperation } + | { kind: 'unreachable' }; + +// The endpoint is deliberately outside /api/v1 and outside auth, so it is +// fetched directly rather than through the API client: it has to answer while +// sessions cannot be written, and the client would prefix it wrongly anyway. +// +// A non-2xx throws rather than resolving to "unreachable": the caller decides +// what a failure means, and it only means "restarting" if we had seen an +// operation first. A proxy answering 404 for a route it does not know about is +// not a compaction in progress. +async function fetchStatus(signal: AbortSignal): Promise { + const res = await fetch('/maintenance/status', { credentials: 'same-origin', signal }); + if (!res.ok) throw new Error(`maintenance status: ${res.status}`); + const op = (await res.json()) as MaintenanceOperation; + if (!op || op.phase === 'idle') return { kind: 'idle' }; + return { kind: 'op', op }; +} + +// Whether a finished operation is recent enough to still be worth showing. +// An operation with no finish time is a live one and is handled elsewhere. +function isFresh(op: MaintenanceOperation): boolean { + if (!op.finished_at) return false; + const at = Date.parse(op.finished_at); + if (Number.isNaN(at)) return false; + return Date.now() - at < TERMINAL_FRESH_MS; +} + +// A full-width strip reporting database compaction on every page. +// +// It owns its polling rather than going through react-query because it has a +// requirement nothing else in the dashboard has: it must keep working while +// its own backend goes away. Compaction restarts the server, so a fetch +// failure here is the *expected* middle of the operation, not an error — +// react-query's default two retries would give up and the cached "ok" would +// linger, showing nothing at exactly the moment the user wants to know what is +// happening. So a failed poll renders "reconnecting" and the interval stays +// short until the server answers again. +export function MaintenanceBanner() { + const [status, setStatus] = useState({ kind: 'idle' }); + // Remembering that we saw an operation is what turns a connection failure + // into "reconnecting" rather than silence: without it, a banner that was up + // would vanish the instant the server restarted. + const sawOperation = useRef(false); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | undefined; + const controller = new AbortController(); + + const poll = async () => { + let next: Status; + try { + next = await fetchStatus(controller.signal); + } catch { + // The server is restarting into the compacted database, or is simply + // down. Either way this is not an error to report. + next = sawOperation.current ? { kind: 'unreachable' } : { kind: 'idle' }; + } + if (cancelled) return; + if (next.kind === 'op') sawOperation.current = true; + setStatus(next); + + const active = + next.kind === 'unreachable' || (next.kind === 'op' && isActivePhase(next.op.phase)); + timer = setTimeout(poll, active ? POLL_ACTIVE_MS : POLL_IDLE_MS); + }; + + void poll(); + return () => { + cancelled = true; + controller.abort(); + if (timer) clearTimeout(timer); + }; + }, []); + + if (status.kind === 'idle') return null; + + if (status.kind === 'unreachable') return ; + + const { op } = status; + + if (!isActivePhase(op.phase)) { + // Terminal. Show the outcome of an operation that outlived its own + // process — but only while it is still news. The journal keeps its last + // entry forever by design, and a permanent banner is not a report. + if (!isFresh(op)) return null; + return ; + } + + const pct = + op.bytes_total && op.bytes_total > 0 && op.bytes_done + ? Math.min(100, Math.round((op.bytes_done / op.bytes_total) * 100)) + : null; + + return ( + + Compacting the database + {op.message ? — {op.message} : null} + {pct !== null ? ( + + {' '} + · {pct}% ({formatBytes(op.bytes_done ?? 0)} of {formatBytes(op.bytes_total ?? 0)}) + + ) : null} + + ); +} + +// The gap between the process re-executing and the new one binding its +// listener. Nothing answers during it — not even the status endpoint — so all +// this can do is say what is happening and count. +// +// The counting is the point. Measured on a real 8.9 GB database: the copy took +// 1m31s, the swap 2s, and coming back up 59s, because the server reloads its +// whole vector index before it listens. A silent minute of refused connections +// after an admin pressed a button reads as a server that died; the same minute +// with a number ticking on it reads as a server that is working. +function ReconnectingStrip() { + const [seconds, setSeconds] = useState(0); + useEffect(() => { + const t = setInterval(() => setSeconds((s) => s + 1), 1_000); + return () => clearInterval(t); + }, []); + return ( + + The server is restarting to adopt the compacted database + + {' '} + — it reloads its index before it can answer, which usually takes about a minute. + Reconnecting… {seconds}s + + + ); +} + +function TerminalStrip({ op }: { op: MaintenanceOperation }) { + const [hidden, setHidden] = useState(false); + useEffect(() => { + const t = setTimeout(() => setHidden(true), TERMINAL_LINGER_MS); + return () => clearTimeout(t); + }, [op.run_id]); + if (hidden) return null; + + // A scheduled reclaim journals its outcome here too, and calling that a + // compaction would tell an admin their server had been restarted when it + // never left service. + const what = op.kind === 'reclaim' ? 'reclaim' : 'compaction'; + + if (op.phase === 'failed') { + return ( + + Database {what} failed + — {op.error ?? 'see the server log'} + · the database was not modified + + ); + } + if (op.phase === 'interrupted') { + return ( + + A database {what} did not finish + {op.message ? — {op.message} : null} + + ); + } + return ( + + {op.kind === 'reclaim' ? 'Database space reclaimed' : 'Database compacted'} + {op.freed_bytes ? ( + — {formatBytes(op.freed_bytes)} returned to the filesystem + ) : null} + + ); +} + +function Strip({ children, busy }: { children: React.ReactNode; busy?: boolean }) { + return ( +
+ + {children} +
+ ); +} diff --git a/server/dashboard/src/app/Shell.tsx b/server/dashboard/src/app/Shell.tsx index ee66ee7c..45118714 100644 --- a/server/dashboard/src/app/Shell.tsx +++ b/server/dashboard/src/app/Shell.tsx @@ -1,31 +1,28 @@ import type { ReactNode } from 'react'; import { Sidebar } from './Sidebar'; -import { Footer } from './Footer'; +import { StatusBar } from './StatusBar'; +import { MaintenanceBanner } from './MaintenanceBanner'; import { UpdateBanner } from './UpdateBanner'; -// Three-row layout: sidebar + main on top, footer spanning the full -// width on the bottom. min-h-0 on the inner row is required so that -//
's overflow-y-auto honors the footer's height when content -// grows tall. UpdateBanner sits above the main row so it spans the -// full width when a newer server release is available. +// The app is a column: [banner] [sidebar | main] [status bar]. +// +// The status bar is a SIBLING of the sidebar+main row, not a child of main — +// it spans the full window width and runs under the sidebar. That is the one +// structural rule of the shell; nesting it inside
is the bug this +// layout exists to prevent. +// +// Pages render their own (header + scrolling content) into
, so +// the page header stays pinned while the content below it scrolls. export function Shell({ children }: { children: ReactNode }) { return ( -
+
+ -
+
-
- {/* Content width scales with the viewport: comfortable reading - width on laptops (max-w-5xl), more room on large monitors so - wide layouts like the Projects table aren't boxed into the - middle with the columns squeezed. Capped at the 2xl screen - width so it never sprawls edge-to-edge on ultrawides. */} -
- {children} -
-
+
{children}
-
+
); } diff --git a/server/dashboard/src/app/Sidebar.tsx b/server/dashboard/src/app/Sidebar.tsx index b8fa3740..1046f4f1 100644 --- a/server/dashboard/src/app/Sidebar.tsx +++ b/server/dashboard/src/app/Sidebar.tsx @@ -1,76 +1,85 @@ -import { LogOut } from 'lucide-react'; import { NavLink } from 'react-router-dom'; import { useAuth } from '@/auth/useAuth'; -import { Button } from '@/ui/button'; import { cn } from '@/lib/cn'; -import { MODULES } from '@/modules/registry'; +import { visibleModules } from '@/modules/registry'; +import { BrandMark } from './BrandMark'; -// Sidebar is rendered from the module registry, filtered by the current -// user's role. A module without `requiredRole` is always visible; a module -// requiring `admin` is hidden from viewers. +// Rendered straight from the module registry, filtered by role, split into +// two blocks: the day-to-day tools and an ADMIN group under a mono rule. // -// New features show up in the sidebar automatically once registered — no -// edits to this component are needed when a module is added. +// Rows carry a 7×7 square marker instead of an icon — accent on the active +// row, quiet otherwise. The label carries the meaning; the marker only says +// "you are here". export function Sidebar() { const { user, logout } = useAuth(); - const role = user?.role ?? 'user'; + const modules = visibleModules(user?.role); + const workspace = modules.filter((m) => m.group !== 'admin'); + const admin = modules.filter((m) => m.group === 'admin'); - const visible = MODULES.filter((m) => { - if (!m.requiredRole) return true; - if (m.requiredRole === 'user') return true; - return role === 'admin'; - }); + const initials = (user?.email ?? '??').slice(0, 2).toUpperCase(); return ( -