<!-- nornir:class: design -->
# `nornir` CLI — fat (embedded) vs thin (client) mode
How the `nornir` **CLI** decides where its data comes from, and how to drive it as
a **thin client** against a running `nornir-server`. The CLI mirrors the viz rule
(see [viz-modes.md](viz-modes.md)) — same gRPC API, same `workspace_<name>`
convention. Added with Rickard, 2026-06-14 (global `--server`/`--token`/`--workspace`
+ cwd workspace pickup + served-workspace reminder).
## TL;DR
- **FAT (embedded):** no server — the CLI opens the local Iceberg **warehouse files
directly** under `<home>/.nornir/...`. This is the default when no server target
is set.
- **THIN (client):** with `--server <url>` (or `$NORNIR_SERVER`) the CLI talks to a
running `nornir-server` over **gRPC**; the server owns the warehouse. Every verb
(`repos`, `test`, `bench`, `mimir`, `knowledge`, `docs`, `diagram`, …) routes to
the server, exactly like the viz tabs do.
- One process owns a warehouse at a time (the embedded-or-client lock), so when a
server is running, use thin mode; fat mode is for offline / single-user / no-server.
## The mode rule
A server target makes it thin; otherwise it's fat:
1. **`--server <url>`** (global flag, any position) **or `$NORNIR_SERVER`** set →
**THIN**. A bare `host:port` gets `http://` prepended.
2. **Neither set** → **FAT/embedded**: the verb opens the local warehouse
(`<home>/.nornir/workspaces/<name>/builds`, see [data-root.md](data-root.md)).
`server_target()` is just `NORNIR_SERVER` (the global `--server` flag sets it in
`main` before dispatch); ~20 verbs check it and call their `*_remote` gRPC path.
## Thin-client invocation
```
nornir <cmd> --server http://oden:7878 --token <tok> [--workspace <name>]
```
- **`--server`** / **`--token`** are **global** — they work in any position
(`nornir test run foo --server … --token …` *or* `nornir --server … test run foo`).
- **`--token`** falls back to `$NORNIR_SERVER_TOKEN`, then the user-readable
`<home>/.nornir/token`.
- **`--server-bin`-style** child processes (viz) inherit `NORNIR_SERVER*` from the
env the global flags set, so `nornir viz --server …` keeps working.
## Workspace resolution (which workspace the command targets)
The `nornir-workspace` gRPC header is resolved in this order (`client_workspace`):
1. **`--workspace <name>`** (global flag) / **`$NORNIR_WORKSPACE`** — the canonical
name = the **`workspace_<name>` suffix** (e.g. `nordisk`, **not** `workspace_nordisk`,
**not** the mono-repo dir `git`). Same names the server's `workspaces_list` and the
viz picker use.
2. **The cwd's `workspace_<name>` component** — standing anywhere under
`…/workspace_nordisk/…` targets `nordisk` (works even before it has a descriptor).
3. **Unresolved** → on a TTY, an interactive numbered **picker** (from
`Workspaces.List`); non-interactive (pipe/CI), an **error listing the served
names** — never a hang, never a bogus name.
If a resolved name isn't served, the gRPC `NotFound` is **enriched** with the
served list + a hint (`on_server`), so a typo points you at the real names.
> The full-screen **TUI** workspace picker (mirroring the viz dropdown over the same
> gRPC API) is a separate, future front-end — the CLI stays scriptable.
## Why a server at all (fat-vs-thin trade-off)
- **Thin** — one `nornir-server` (user `nornir`) is the sole warehouse writer; CLI,
viz, and MCP are all gRPC clients of it. Remote/shared, monitored, always-fresh.
Remote host: `--server http://oden:7878 --token <tok>` (token from
`/etc/nornir/nornir.env` on the server, or `~/.nornir/token`).
- **Fat** — direct warehouse-file access, no server, good offline / single-user.
Cannot run while a server holds the catalog lock on the same warehouse.
## The unified job ledger (`jobs.redb`)
Every long-running nornir operation (~1s–minutes) — release runs, workspace
fetch/republish, `docs book`/export/render, knowledge scan, deepscan, bench, test
matrix, arch generate, index build, vector embed, bakeoff, airgap — records its
**lifecycle** (`running → done|failed` + kind · target · start/end/elapsed + a
`result_ref` link to the domain result) into **one durable list**. Added with
Rickard, 2026-06-18 (consolidates what used to be ad-hoc per-op tracking).
This is **orthogonal to the result tables**: Iceberg (`bench_runs`, `test_results`,
`architecture_wiring`, `doc_exports`, `clone_events`, …) records *what an op
produced*; the ledger records *that an op ran*. `result_ref` (e.g.
`release_events:<run_id>`, `bench_runs:<uuid>`) links the two.
**Storage = redb, not Iceberg.** A job record is mutable (overwritten in place as
it advances) and short-lived, so a redb key/value file is the right tool — no
append-only coalescing, no per-event Iceberg snapshot (that small-files antipattern
is why Iceberg is wrong here). It's a **distinct `jobs.redb`** instance, separate
from skade-katalog's `catalog.redb` and the server's `registry.redb`, living beside
the warehouse. Retention prunes terminal jobs (older than 30d / over a row cap) so a
long-lived server stays bounded.
**Access mirrors the warehouse exactly — fat vs thin:**
- **FAT** (local FS via library code, no gRPC): the op's `JobHandle` opens
`<warehouse root>/jobs.redb` and writes directly. The viz reads it lock-tolerantly
(`JobStore::open_read_only`, the same copy-aside-snapshot trick as the warehouse).
- **THIN** (no local FS): the `JobHandle`'s sink submits each lifecycle record over
gRPC (`Telemetry.SubmitJobs`); the server applies it to *its* `jobs.redb`. Reads
come back over `Viz.Jobs` (paginated — last N newest-first, so a long ledger never
overflows the gRPC decode cap). Same split as `bench`/`test` result submission.
- **The server itself** (the `Ops.*` handlers + the monitor/sweep) is fat to its own
`jobs.redb`. The server is the sole writer of its file; a fat dev-box client writes
its own local one — so two processes never contend.
The **🧬 nornir viz tab** shows the list: the reusable, nornir-agnostic
`facett-jobview` component (a Windows-Task-Manager-style table — kind · target ·
status · elapsed · detail), fed by mapping `jobs::JobRecord → facett_jobview::JobEntry`
on a ~1.5 s poll (swappable to skade streaming-on-commit later). Closing the viz
while jobs are still running pops an **"ask at close"** confirm.
Source: `src/jobs.rs` (`JobStore` / `JobHandle` / `JobRecord` / retention),
`src/bin/nornir.rs` (`start_job` — the fat/thin sink helper), the `Ops.*` handlers +
`Telemetry.SubmitJobs` + `Viz.Jobs` in `src/bin/nornir-server.rs`,
`src/viz/nornir_tab.rs` (the tab), and the `facett-jobview` crate (the widget).
## Source map
- Global flags + env wiring + workspace picker: `src/bin/nornir.rs`
(`main`, `cwd_workspace_name`, `env_workspace_or`, `client_workspace`,
`served_workspaces_remote`, `pick_workspace_or_explain`, `on_server`).
- Canonical name derivation: `src/config.rs` (`Loaded::workspace_name`, `discover`).
- RPCs: `proto/nornir.proto` (`Workspaces.List`, `Repos`, …).
See the copy-paste recipes in [cli-thin-client-for-idiots.md](cli-thin-client-for-idiots.md).