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 (
-