the build pipeline — *.runa.ron, and where it goes next · 2026-09-10
Class:
design· Function axis:f0the word is runa ·f1what exists ·f2a runa is hand-written besidextask·f3one runa, fat or thin, same semantics ·f4declarative vs procedural Vocabulary: variant → features → settings / sub-settings → typ. Never "cell".
f0 · The word is runa, not "rune"
Measured, not preferred. The crate is dwarves-runa (edda/crates/dwarves-runa, 0.2.2), the format is *.runa.ron, the modules are runa_exec / runa_backend, the types are dwarves_runa::{Pipeline, Stage, Step, Trigger}. "rune" occurs in exactly one place in the tree — the logo prose "two dwarves at the rune-anvil forge" (dwarves-gui/src/about.rs:11). Zero types, zero modules, zero file names. The bare crates.io name runa was taken; that is the whole reason for the dwarves- prefix (dwarves-runa/src/lib.rs:9). Write runa.
f1 · What is there — the datasheet
| part | where | what |
| the format | *.runa.ron → Pipeline { name, triggers[], stages[ Stage { name, needs[], steps[] } ] } |
edda/crates/dwarves-runa/src/pipeline.rs:19 |
| the step catalog | 22 typed variants, #[serde(deny_unknown_fields)] |
pipeline.rs:212 |
| validation | duplicate stage · unknown needs · DAG cycle, at LOAD |
dwarves-runa/src/validate.rs:24, reusing funnel_core::PlanDag::topo_order |
| the executor | RunaStepRunner — borrows a &jera::agent::Scheduler, owns none |
crates/dwarves/src/runa_exec.rs:184 |
| step → work | step_payload() maps a Step to a WorkPayload + caps |
runa_exec.rs:638 |
| the runner | ForgeBackend — one LocalAgent "local-0", caps [cargo, container, kvm] |
crates/dwarves/src/runa_backend.rs:195 |
| no shell | argv-only Command::new(program).args(args); there is no Shell/Run/Script step |
runa_backend.rs:238; policy at pipeline.rs:182 |
| dry is the default | pipeline run without --execute uses a fake scheduler that greens every unit |
main.rs:1925; the closure at pipeline_wizard.rs:442 |
| a second, file-less caller | run_build_chain_watched SYNTHESISES a Pipeline in Rust and runs it through the same executor |
crates/dwarves/src/build_steps.rs:874 |
Two verbs, two shapes: dwarves run <f>.runa.ron is always real; dwarves pipeline run <f> is dry unless --execute. Every pipeline verb already prints one JSON object for agents (main.rs:302) — there is no --json flag because JSON is the only output. There is no 3-col human table; that is the crisping gap, not a missing machine format.
f2 · A runa is written by hand, beside xtask — and an xtask verb is a step
The intent: a Rust developer commits a *.runa.ron next to their repo's xtask, and the verbs they already wrote and tested become callable steps. Half of that shipped: Step::Xtask { cmd } lowers to cargo xtask <cmd> (runa_exec.rs:744) and both committed pipelines use it (dwarves.runa.ron:40). The gap is that the step could name neither which repo nor any argument, so cargo xtask build --variant korp-ui — the constellation's most common verb — was unspellable, and every step ran in the forge's own checkout.
The slice implemented in this lane closes exactly that and nothing more:
Xtask(cmd: "build", args: ["--variant", "korp-ui"], repo: "korp")
args and repo are #[serde(default)], so every existing file stays valid under deny_unknown_fields. repo is a checkout name, resolved as a sibling of the forge's root and refused by name if it is absolute, empty, or contains .. — a pipeline step is not a door onto the filesystem. It lowers to the existing ProcessSpec.cwd, which the backend has honoured since day one (runa_backend.rs:241). No new machinery.
f3 · One runa, fat or thin — the seam is the AGENT ROSTER
Today *.runa.ron is fat only, and says so: dwarves run … --server prints "a *.runa.ron run executes FAT (local jera Scheduler); ignoring --server" (main.rs:1765), and runa appears zero times in thin_client.rs and server.rs.
The seam is already cut, one layer below where people look for it. RunaStepRunner holds a borrowed &Scheduler (runa_exec.rs:185); it never asks who will run a unit. Who runs it is decided entirely by which agents are registered:
| roster | what it means |
ForgeBackend::into_scheduler → one LocalAgent |
fat — this machine (runa_backend.rs:195) |
pipeline_wizard::fake_scheduler → one always-green agent |
dry (pipeline_wizard.rs:442) |
a GrpcAgent registered against dwarves-server |
thin — named in prose at agent_bridge.rs:8, not wired |
So "the same runa runs locally or on the forge with the same semantics" is not a new executor and not a --server branch in runa_exec: it is register a different agent. That is the seam to name and the one to build — and it is the same shape the build already has, where container-vs-host is decided by [build.toolchain] being present rather than by a flag.
f4 · Declarative vs procedural — the line
| declarative | procedural | |
| what | build-thing: variant → features → settings/sub-settings → typ | runa: stages, needs, ordered steps |
| who owns it | <repo>-build-thing (impl BuildThing), toml EMITTED from it |
a hand-written *.runa.ron beside xtask |
| the question | what can be built, and what does a toggle cost | what shall be done, in what order, on what |
| who may branch | nobody — a declaration has no if |
a compiled Step::Xtask, never the RON |
The rule that keeps them apart: a runa step names a build; it never describes one. Step::Build { variant, opts, params } carries a coordinate and the operator's picks and hands them to the evaluator — it does not carry features, a profile or a target, because those are the declaration's and copying them into a pipeline file forks the source. Conversely a build-thing declaration never carries an order: it says what --build kernel consumes, and the DAG derives the order (build_dag.rs:719).
The improvement the flow audit points at
.nornir/assets/build-flow.svg (generated, cargo xtask build-flow-svg) counts 10 hard-coded steps against 8 declared on the Build{variant} path. The finding that matters is not any one of them — it is that BuildPlan / PlanStep already exist, typed, transported over the wasm ABI (edda/…/plugin.rs:767, :790, abi::OP_PLAN = 9), with a doc that states the intent verbatim: "The build thing never performs an action: it returns a plan and the forge executes it." And BuildPlan appears zero times in the whole dwarves repo. dwarves loads a recipe module and asks it three things — manifest(), variants(), menu() (build_thing/code.rs:344-346) — then runs the same imperative Rust chain the toml road runs.
So the flow becomes data by executing the plan that is already emitted, not by inventing a plan format: teach code.rs to ask plan(), and give the forge a small interpreter over PlanStep { name, program, args, env } that runs each step through the same ForgeBackend a runa stage runs through. A runa Step::Build then lowers to the plan the evaluator emitted rather than to a marker the backend special-cases (BUILD_THING_MARKER, runa_backend.rs:52), and the two pipelines — the build chain and the CI pipeline — become one interpreter over one typed plan. That is the next step, and it is a wiring job, not a design one.
See also: build-thing-code-is-source-design.md · build-thing-inventory-2026-09-10.md · assets/build-flow.svg
Addendum · auto-scan and the missing Refresh (audited 2026-09-10)
Auto-scan already works, and the per-pane Scan buttons are already gone. A populate (Workspaces.Register eager, Workspaces.Fetch, or the monitor poll loop) funnels through republish_served_locked (nornir/src/server_grpc.rs:6379), and at :6426 / :6433 it calls LensChain::submit_feeds then submit — one jera chain job per lens per population event, keyed lens:{workspace}:{repo}:{snapshot_id}:{lens} (nornir/src/lens_chain.rs:210), grouped lens:{workspace}:{repo} (:227), with a generation that is an FNV-1a over the current Iceberg snapshot ids of all 16 lens-read tables (:297) — so it moves exactly when a populate commits rows a lens reads. Dedup is ChainQueue's (edda/crates/jera/src/jobs/chain.rs:196): a repeat submit returns Duplicate(id) and queues nothing; a higher generation supersedes every lower-generation PENDING job in the group (:314). A second layer catches anything that slips past: run_job short-circuits when lens_runs already has a clean row for the (workspace, lens, repo, snapshot_id, params) (lens_chain.rs:397). Feeds go at Priority::Normal, lenses at Low, one worker — so a producer always drains before the fold that needs it. The panes read on open and have no Scan control; the deletions are cited in place (dwarves-gui/src/mimir_lens.rs:2111, :1010; warehouse_select.rs:225). No work is needed here. The rule is implemented.
What is missing is the Refresh. There is no clear-and-repopulate verb — no Warehouse.Reset, no Mimir.Refold, nothing on the Workspaces service (edda/crates/nornir-wire/proto/nornir.proto:1073). Three unlinked pieces exist: nornir warehouse reset --yes (CLI-only, fat-only, refuses while the server holds the catalog lock, nornir/src/bin/nornir.rs:17057, and does not repopulate), Workspaces.Remove{purge} (deletes the workspace outright), and Workspaces.Fetch{force} (repopulates without clearing — its own proto comment says "e.g. the warehouse was cleared and must be rebuilt", nornir.proto:1102). The operator recipe today is four manual steps across a server stop/start, and nothing verifies the sequence.
The shape it should take: one RPC, Workspaces.Repopulate { workspace, purge: bool }, that takes the same populate lock republish_served_locked takes, refuses BY NAME with the in-flight job's id when a populate is already running (the lock is already there — the refusal is what is missing), clears the workspace's warehouse in-process rather than asking the operator to stop the server, then runs the ordinary populate path so the lens chain re-arms itself through the generation bump it already computes. One visible Refresh per repo in the UI drives exactly that, and nothing else in the pane needs a button. This is a nornir-side change and is named here rather than made: this lane's repos are dwarves, edda and facett.