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

nordisk/dwarves

.nornir/dwarves-design.md

⚒ dwarves — the CI/build-server (continuum) — design

📍 THIS DOC LIVES IN dwarves (moved from nornir/.nornir/, 2026-07-22, move=delete). The code it documents is dwarves' own (crates/dwarves/src/…), so the doc follows the code. nornir keeps only the read-only viz surface that renders this engine's output. Source links below are dwarves-repo-relative.

Class: design · Function axis: f0 the umbrella (forge + scheduler → a running CI server) · f1 the brokkr forge (re-exported) · f2 the sindri scheduler (cron / poll→enqueue / drain) · f3 the host backends dwarves owns (RealRunner / DemoRunner / engine / store) · f4 standalone tool + callable lib + server embed · f5 the read surface (5 facets + Tab::Brokkr + CLI + badge + uiplane cell). Strict: this file states only what dwarves is. Sibling how-to: dwarves-guide.md; zero-knowledge walkthrough: dwarves-for-idiots.md. The FORGE half is documented in depth in brokkr-design.md.

Named for the two dwarf-smith brothers who forged Mjölnir — Brokkr worked the bellows while Sindri directed the forge. Here brokkr owns the pipeline model and sindri schedules it; dwarves is the umbrella over both.

f0 — dwarves, the whole (crates/dwarves)

brokkr wrote and unit-tested every pure CI model — trigger firing, the build queue admission verdict, the agent pool, cron-shaped triggers, the poll bridge — but nothing ever drove them: there was no scheduler and no standalone binary. BuildQueue, AgentPool, deliver_with_retries, run_matrix, runs_for_report had no live caller. dwarves closes that gap. It is:

dwarves may depend on the dep-light backbones (brokkr, nornir-build-thing, nornir-jobs) but never on the full nornir crate — no cycle. Zero shell (LAW 7): every runner is a cargo/bollard/tunnr Rust API, argv-only.

part lives in what it is
f1 forge brokkr (re-exported dwarves::brokkr) pipeline-as-data model, descriptor, DAG, plan, run engine, 5 Elm facet cores
f2 scheduler sindri.rs Sindri::tick cron eval → poll enqueue → drain the queue against the pool → dispatch
f3 backends runner.rs RealRunner/DemoRunner; engine.rs run_pipeline_job; store.rs real cargo build (zero shell) / demo; the CI_BUILD job wrap; JSONL history
f4 tool + embed main.rs; nornir-server.rs sindri_sweep standalone CLI; the server's poll→run CI leg
f5 read surface src/viz/brokkr_tab.rs; nornir brokkr … 5 facets, Tab::Brokkr (now a uiplane cell), CLI, badge

f2 — sindri, the scheduler (the missing host driver)

sindri is the live loop that DRIVES brokkr's pure models. One Sindri::tick(now, report, runner, sink, warehouse) does, in order:

  1. cron — enqueue_cron(now): every registered pipeline whose cron trigger matches this minute (and has not already fired it) enqueues a run. The pure core is [cron_matches] / [cron_next_fire] (a 5-field min hour dom mon dow evaluator supporting *, */n, a-b, a,b), unit-tested without a clock.
  2. poll → enqueue — enqueue_poll(report): given a [MonitorReport] (which projects' HEAD moved), [runs_for_report] enqueues one run per changed project that carries a poll trigger. MonitorReport is a dep-light stand-in for nornir's monitor::FetchReport so sindri never depends back on nornir.
  3. drain — drain(runner, sink, warehouse): walk the FIFO queue, ask brokkr's pure admit verdict whether each item may start now (a capable idle agent, past the exclusive gate), dispatch the admitted ones through [run_pipeline_job] (a tracked CI_BUILD job) and historize them. Blocked items stay queued with a reason (RAGNARÖK: never a silent stall); the exclusive gate admits one exclusive build per drain.

Every tick returns a [TickReport] (cron_fired, enqueued, dispatched, blocked) — the observable outcome that drives the facets, the server log line, and the tests. Sindri::serve is the thin sleep-loop over tick; it is synchronous by design (no async runtime), so the standalone tool and the embedded server drive it identically.

The pure decisions (cron-next-fire, drain admission order, the exclusive-gate serialization) are unit-tested separately from the loop.

f4 — standalone + callable + embedded (the modgunn shape)

Exactly modgunn's split: src/lib.rs is the callable API (dwarves::…) and a [[bin]] dwarves (src/main.rs) is the standalone tool, with clap behind the default cli feature so a lean embedder (nornir) pulls the lib clap-free (default-features = false). nornir depends on it via a path dep (nornir/Cargo.toml) beside brokkr.

Server embed (Phase 0). nornir-server's poll sweep already computes which members' HEAD moved (server_sweep → rep.changed). The new sindri_sweep helper discovers each changed member's brokkr.toml, registers its pipeline, and ticks a Sindri once with a MonitorReport for the changed set — poll → enqueue → drain → dispatch through the job-tracked engine, historized to the served warehouse. This is what makes the running nornir-server a CI server.

f5 — the read surface

The five facett Elm cores (results / tree / DAG-as-MetroView / agents / log) live in the brokkr leaf and render in Tab::Brokkr; each keeps a state_json slice so the whole tab is drivable headless. Tab::Brokkr is now a declared uiplane cell (TAB_PANE_BLOCK → ("Brokkr","brokkr")) so the LAW-9 walk runs a green row for it instead of a silent hole. A self-driving robot scenario (tests/viz_brokkr_robot.rs robot_dwarves_run_lights_the_dag_stations_green) drives a run through the tab and asserts the DAG stations transition dark → lit green (visible movement).

Warehouse seam

History is JSONL under <warehouse>/brokkr/ (store.rs), the dep-light fallback. The rows are already Iceberg-shaped (PipelineRunRow / BuildRow), so the brokkr_runs / brokkr_steps Iceberg tables are a drop-in swap behind append_history / read_runs with no caller change.

Phase 2/3 seams left as TODO (deliberately out of Phase-0 scope)

Documented here so they are not silently forgotten: