Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

.nornir/workspace-design.md

The nornir workspace — design

Class: design · Function axis: f0 (the workspace as one system) + f1 descriptor · f2 registry · f3 data-root · f4 register/autopopulate · f5 the workspace CLI verbs. Strict: this file states only the design that is — architecture, laws, data-flow, source pointers. Every why, every rejected alternative, lives in workspace-reasoning.md. How to drive it is in workspace-guide.md; the cold-shell walkthrough is workspace-for-idiots.md.

f0 — the workspace as one system (the whole)

A nornir workspace is the appointed set of N independent repositories the release/build/warehouse pipeline treats as one unit. It is not a directory of code — it is three co-operating parts that together name, index, and store that set:

part is lives source
descriptor the SOURCE list of members ([repos.*] + their git/path) workspace_<name>/nornir-workspace.toml (control plane, git-tracked) src/workspace/descriptor.rs
registry the redb source-of-truth row indexing the workspace + its members' sync state <home>/.nornir/workspaces/registry.redb (the ONLY global file) src/registry.rs
data-root the per-workspace git/ + builds/warehouse/ + funnel/ <home>/.nornir/workspaces/<name>/ src/config.rs

Two planes, one workspace:

The server ≡ user law. Fat (a user on their laptop) and thin (the daemon on host oden, running as system user nornir) are the identical layout, computed by the same code (config::nornir_home() reads $HOME). The only difference is who fills git/: you check it out, or the server clones it. There is no server-special path (data-root.md).

Reuse map (what the workspace subsystem uses). The descriptor is parsed by src/workspace/; the dep-graph built from it lives in crate::warehouse::dep_graph (graphs are historical artefacts Urðr remembers, so analysis + persistence are co-located there). Register/populate drives monitor::{fetch_workspace, republish}, which drive the warehouse, code index, and nornir-testmatrix/bench engines. The registry row is read by nornir workspace ls, the Workspaces.* gRPC service, and the viz Nornir-tab roster.

f1 — the descriptor (nornir-workspace.toml)

A single TOML file lists every member by name and gives its source. Parsed into WorkspaceDescriptor (descriptor.rs:29).

[workspace]
name = "holger-suite"
# deep_scan = true   # opt-in: republish also scans the FULL transitive dep closure

[repos.holger]
path = "../holger"                               # a local (fat) checkout

[repos.znippy]
git = "https://codeberg.org/nordisk/znippy"      # a git member
# branch = "main"                                # optional pinned ref

[repos.korp]
path = "../korp"                                 # BOTH: local checkout…
git  = "git@codeberg.org:nordisk/korp.git"       # …with a remote to auto-materialize from
symbol shape source
WorkspaceDescriptor { workspace: WorkspaceMeta, repos: BTreeMap<String, RepoSpec>, descriptor_dir } descriptor.rs:29
WorkspaceMeta { name, deep_scan } descriptor.rs:40
RepoSpec { path?, git?, branch? } descriptor.rs:50
RepoSource Path { resolved, fallback: Option<GitRef> } | Git(GitRef) descriptor.rs:60
GitRef { url, branch } descriptor.rs:76

Source resolution laws (RepoSpec::source, descriptor.rs:82):

Loading. WorkspaceDescriptor::load(path) reads + parses the file and canonicalizes descriptor_dir (descriptor.rs:104). from_content(&str) parses raw TOML with no file — the seam that lets the registry store the descriptor in redb and re-read members/deep_scan from the row (descriptor.rs:123). sources() returns (name, RepoSource) in deterministic BTreeMap order (descriptor.rs:129).

Discovery. workspace::discover_descriptor(start) walks up from start (cwd → parent → filesystem root) returning the first nornir-workspace.toml found, so a user standing in a workspace_<name>/ dir needs no --descriptor flag (mod.rs:29).

Materialize plan (resolve.rs). resolve_sources(desc) maps every member to a concrete local path (resolve.rs:86). For a Path member the pure decision is plan_path(present, fallback) -> PathPlan (resolve.rs:47):

present? fallback? PathPlan action
yes — Use use the checkout as-is
no Some Materialize { url, branch } clone the remote into path, then use it
no None Missing hard error naming the member + path

A checkout is "present" iff path is a non-empty directory (path_is_present, resolve.rs:60) — an empty dir from a failed clone is re-materialized. Materialize clones pure-Rust via gitio::clone_or_fetch (HTTPS via gix, SSH via russh — no shell-out), then best-effort checks out branch (materialize_member, resolve.rs:150). A git-only member resolves to a deterministic cache dir under $NORNIR_CACHE_DIR (git_cache_dir, resolve.rs:71).

f2 — the registry (redb source-of-truth)

A single redb file at <root>/registry.redb is the persistent index of every workspace the server tracks. One table, TABLE: &str → &[u8] — workspace_name → serde_json(Workspace); bytes in, bytes out; the server is the sole writer (registry.rs:24).

The row (Workspace, registry.rs:114):

field meaning
name key + on-disk dir segment (validated by validate_ws_name)
mode Monitored | Pushed | External
descriptor the seed: a local path or git URL to nornir-workspace.toml
poll poll interval for monitored workspaces (e.g. "60s")
current_snapshot Iceberg snapshot id the warehouse publishes; empty ⇒ none yet
members Vec<MemberState>
descriptor_content the descriptor's raw TOML, stored IN the row — the self-contained source of truth
created_at / updated_at RFC3339 timestamps

descriptor_content is the source of truth. The poll loop and every later republish re-read the descriptor TOML straight from the row (WorkspaceDescriptor::from_content(&ws.descriptor_content)), never a sibling descriptors/*.toml on disk. Workspace::new reads a local descriptor file and captures its content into the row (registry.rs:142); Workspace::from_content is the thin-mode seam where the client ships the TOML over RPC and the server stores it with no disk touch (registry.rs:169).

Mode (registry.rs:56):

mode source of git/ as_str
Monitored server polls member git URLs and republishes on a HEAD change "monitored"
Pushed a thin client pushes its working-tree source in; server computes "pushed"
External sources live outside (fat external checkout); only builds/ is server-owned, git/ stays empty "external"

Mode::parse maps any unrecognized string to Pushed (registry.rs:77).

MemberState (registry.rs:87): name, remote (mirrors RepoSpec.git), git_ref (empty ⇒ main), last_seen_sha, last_synced, sync_state ("ok"/"fetching"/"error: …"/""), and the worktree-freshness pair worktree_digest + worktree_dirty (sees uncommitted edits the SHA cannot; equals gitio::CLEAN_WORKTREE_DIGEST when clean).

Registry API (registry.rs:229):

method effect
open(root) open/create <root>/registry.redb, materialize the table
upsert(&ws) insert or replace a row
get(name) / list() read one / all (name order)
set_snapshot(name, snap) atomically set only current_snapshot + updated_at in one write txn (closes the republish read-modify-write clobber window)
remove(name, purge) drop the row; with purge also remove_dir_all the builds/ dir
builds_dir(name) <root>/<name>/builds

Name safety. validate_ws_name (registry.rs:35) rejects empty, ./.., anything with / or \, and absolute/multi-component names before the name is joined onto the root and handed to remove_dir_all. remove(.., purge=true) additionally canonicalizes the resolved builds/ and asserts it still starts with the registry root before the irreversible wipe, and runs the wipe even when no row exists (orphan cleanup) (registry.rs:343).

The roster projection. RosterRow (registry.rs:521) is the shape both the fat (registry Workspace) and thin (proto) faces map into so workspace ls and the viz Nornir-tab render identically: name · mode · members · last_synced plus the optional --status roll-up (populate ∈ green/red/stale, failing_member, last_error). roster_outcome(rows) renders it as a uniform CommandOutcome (registry.rs:547); an empty registry is RED for the autopilot.

f3 — the data-root layout

nornir keeps everything under <home>/.nornir, one folder per workspace, no path env-vars (data-root.md):

<home>/.nornir/
  workspaces/
    registry.redb             # the ONLY global file (the index)
    <name>/                   # one folder per workspace
      git/                    # member source: server clones here / fat = your checkout
      builds/
        warehouse/            # THIS workspace's Iceberg warehouse (catalog.redb, jobs.redb beside it)
      funnel/                 # THIS workspace's idea→plan funnel

The data root is derived from the running user's $HOME — one rule for everyone. Canonical resolvers in src/config.rs:

resolver returns source
nornir_home_from(home) home.join(".nornir") (pure) config.rs:29
nornir_home() nornir_home_from(home::home_dir()) config.rs:36
registry_root() nornir_home().join("workspaces") config.rs:42
Loaded::workspace_name() the <name> from a workspace_<name> path component (else default) config.rs:257
Loaded::warehouse_root() registry_root()/<name>/builds (byte-for-byte the path the server opens) config.rs:274
Loaded::funnel_root() registry_root()/<name>/funnel config.rs:287

home::home_dir() reads $HOME (falling back to getpwuid), so the same code yields /home/rickard/.nornir for a login user and /home/nornir/.nornir for the systemd service. An absolute [storage].local_path in nornir.toml still pins the warehouse to an explicit path (a deliberate per-config override, config.rs:274); a relative local_path joins workspace_root. nornir root prints the resolved home.

f4 — the register / autopopulate flow

register is EAGER by default (workspace-autopopulate.md, creating-a-workspace.md): it records the row and runs the same record → fetch → build pipeline the poll loop runs on a change, inline, so the workspace is populated now. Handler WorkspaceOp::Register (nornir.rs:5794):

① registered  <name> (N member(s))     # reg.upsert — seed members from the descriptor
② fetching    N member(s)…             # monitor::fetch_workspace → clone into <root>/<name>/git/
③ building    warehouse…               # monitor::republish → dep graph + knowledge + index + snapshot
✓ registered  <name> — populated now (snapshot …) at <root>/<name>/builds

Thin vs fat. In thin mode (NORNIR_SERVER) the register + fetch route over RPC and the server republishes from the fetched change. A discovered local descriptor's content is read client-side and shipped in RegisterWorkspaceRequest.descriptor_content (proto field 6), because the server is a different host and cannot read the client's path; a --descriptor that is a git URL or an already-server-readable path is passed through with empty content (descriptor_is_local_file / resolve_descriptor_content, nornir.rs / nornir-server.rs).

The poll loop is the automated register. A monitored server re-fetches + rebuilds on a git HEAD change (monitor.rs). Nothing differs in kind from the manual button — only who pushes it.

Autopopulate (EPIC POPULATE). On register + on each poll tick, republish_with runs the full member-bootstrap pipeline (workspace-autopopulate.md): (1) dep graph, (2) knowledge + security scan, (3) BM25 index, (4) Iceberg snapshot, (5) dep-graph persist, then the light half — (6) test-surface discovery → NOT-RUN rows, (7) a light test matrix (build·unit·doctest·clippy·fmt·audit, NORNIR_SKIP_HEAVY=1), (8) a bench smoke (NORNIR_BENCH_QUICK=1). Every step (plus clone) emits a release_events row (run_id·seq·ts·component·repo·op·phase·status·detail·…) the viz run/event pane renders — no new table. Sources: monitor::{fetch_workspace, republish_with}, crates/nornir-testmatrix, src/bench/, warehouse tables release_events / test_results / bench_runs.

Auth (the one setup difference). Member sources clone pure-Rust; SSH remotes read a per-user deploy key — server → /home/nornir/.ssh/id_ed25519 (created by nornir install), fat user → ~/.nornir/ssh (created by nornir key generate, its public key registered as a codeberg deploy key). HTTPS-public members need no key.

f5 — the nornir workspace CLI verbs

WorkspaceOp (nornir.rs:515); each verb routes fat (local Registry) or thin (RPC).

verb args does source
ls [--json] [--status] list workspaces; --status adds the POPULATE roll-up column nornir.rs:5749
show <name> print one workspace's full record nornir.rs:5788
register <name> [--descriptor] [--monitored|--external] [--poll] [--lazy] eager record + fetch + build (--lazy = record only) nornir.rs:5794
add <name> (same as register) eager alias nornir.rs:5866
rm <name> [--purge|--data] drop the row; --purge also wipes builds/ nornir.rs:5915
fetch <name> [--force] clone/fetch every git member into git/, update sync state; --force full rebuild nornir.rs:5926
populate <name> clone + republish now (async build on the server) nornir.rs:5970
events <name> [--json] print recent clone/fetch outcomes from the clone_events table nornir.rs:5987

--monitored and --external are mutually exclusive (nornir.rs:5802).