Gunnar

nordisk/nornir

.nornir/cli-client-modes.md

<!-- 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).