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) — 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 runningnornir-serverover 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:
--server <url>(global flag, any position) or$NORNIR_SERVERset → THIN. A barehost:portgetshttp://prepended.- Neither set → FAT/embedded: the verb opens the local warehouse (
<home>/.nornir/workspaces/<name>/builds, see 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/--tokenare global — they work in any position (nornir test run foo --server … --token …ornornir --server … test run foo).--tokenfalls back to$NORNIR_SERVER_TOKEN, then the user-readable<home>/.nornir/token.--server-bin-style child processes (viz) inheritNORNIR_SERVER*from the env the global flags set, sonornir viz --server …keeps working.
Workspace resolution (which workspace the command targets)
The nornir-workspace gRPC header is resolved in this order (client_workspace):
--workspace <name>(global flag) /$NORNIR_WORKSPACE— the canonical name = theworkspace_<name>suffix (e.g.nordisk, notworkspace_nordisk, not the mono-repo dirgit). Same names the server'sworkspaces_listand the viz picker use.- The cwd's
workspace_<name>component — standing anywhere under…/workspace_nordisk/…targetsnordisk(works even before it has a descriptor). - 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(usernornir) 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.envon 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
JobHandleopens<warehouse root>/jobs.redband 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 itsjobs.redb. Reads come back overViz.Jobs(paginated — last N newest-first, so a long ledger never overflows the gRPC decode cap). Same split asbench/testresult submission. - The server itself (the
Ops.*handlers + the monitor/sweep) is fat to its ownjobs.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.