dwarves-gui/src/build_steps.rs
//! **The step console** — the edge DAG a `BYGG <kant>` click runs, shown honestly
//! BEFORE it runs and while it does (wave 3, gap #4 of
//! `.nornir/build-thing-model-design.md`: `[BYGG kärnan]` ≡ `--build kernel`).
//!
//! ```text
//! steg DAG-ordning: donatorn först, sedan konsumenten
//! rad <#> <roll> <koordinat : typ> <tillstånd> <tid> <förväntan kallt / cachat>
//! ```
//!
//! Five step states, and the console draws every one distinguishably — a robot
//! reads the word, an operator reads the row:
//!
//! | state | word on screen | data |
//! |---|---|---|
//! | [`StepState::Queued`] | `köad` | — |
//! | [`StepState::Running`] | `kör i container <image@digest>` | the image |
//! | [`StepState::Cached`] | `cachad ur ledgern <nyckel>` | the ledger key |
//! | [`StepState::Done`] | `klar · kvitto sha <sha> · fragment <sha>` | sha, fragment |
//! | [`StepState::Refused`] | `vägrad: <namngiven orsak>` | the reason, verbatim |
//!
//! # No fake progress
//!
//! The console knows nothing about how a step runs. It is fed by a
//! [`Forge`](crate::build_scene::Forge) through `build_edge` / `steps`, and when the
//! forge has no runner it says so BY NAME ([`MISSING_RUNNER`]): the donor step is
//! refused with that sentence, the consumer step is refused because its donor was,
//! and nothing spins. The EXPECTATION line (cold minutes vs cached seconds) is drawn
//! before the run starts and is labelled as an expectation, never as a measurement —
//! its numbers name their source ([`Expectation::source`]).
//!
//! The shipped forge HAS a runner: [`ChainRun`] carries
//! `dwarves::build_steps::run_build_chain_watched` — the CLI's own chain, its own
//! registry, its own runa — onto a worker thread and drains its
//! [`StepProgress`](dwarves::build_steps::StepProgress) into these five states once
//! a frame. Everything the console draws after a click is something the runner said.
//!
//! # Two receipts (law E), a third beside them
//!
//! The BUILD receipt is in the artifact; the RUN receipt
//! ([`crate::actions::RunReceipt`]) says what happened when it ran. A `BYGG <kant>`
//! writes an [`EdgeBuildReceipt`] beside them: the verb, the CLI line it equals, the
//! DAG it planned and how it ended — the re-reader's answer to "what did the chained
//! build do".
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use egui::{Color32, RichText, Ui};
use nornir_build_thing::variant::ArtifactKind;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// **The chain runner, spelled once** — the road a `BYGG <kant>` takes, and the road
/// a forge that takes no road is measured against.
///
/// Until 2026-08-27 this constant named `BuildRun::spawn_chain`, a function that has
/// never existed in any crate, and BOTH arms of the shipped forge's `build_edge`
/// returned it: the click planned the DAG, judged the edge at the CLI door's own
/// judge, and then refused itself for want of a runner that was sitting one crate
/// over the whole time ([`dwarves::build_steps::run_build_chain`], landed in
/// `fba8384`). A refusal that names a road nobody can walk is worse than no
/// refusal — it sends the reader looking for the wrong thing.
///
/// The shipped forge now walks it. What is left for this to name is a forge that
/// wires no runner at all — a fixture, a test double — and the honest sentence for
/// that one is that THIS forge has none, not that the road is missing.
pub const MISSING_RUNNER: &str = "dwarves::build_steps::run_build_chain_watched";
/// The refusal a forge with no chain runner gives — the button says this, by name.
#[must_use]
pub fn missing_runner() -> String {
format!("den här smedjan kopplar ingen kedjerunner ({MISSING_RUNNER})")
}
/// Where a step sits in the DAG.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StepRole {
/// The consumed build thing — built first.
Donor,
/// The variant that consumes it — built last.
Consumer,
}
impl StepRole {
/// The word on the row.
#[must_use]
pub fn word(self) -> &'static str {
match self {
StepRole::Donor => "donator",
StepRole::Consumer => "konsument",
}
}
}
/// **One step's state** — the five words the console draws.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "lowercase")]
pub enum StepState {
/// Not started; waits on the steps before it.
Queued,
/// Running inside the declared toolchain container.
Running {
/// `image@digest` — the container the step runs in.
///
/// EMPTY when the step's variant declares no toolchain image: that build
/// takes the HOST's environment (blocker B1 —
/// `dwarves/crates/dwarves/src/runner.rs:217` spawns cargo in it), and the
/// line says so instead of naming a container that never ran.
image: String,
},
/// Not built: the ledger held the artifact behind its pin key.
Cached {
/// The ledger key it was found under.
key: String,
},
/// Built, with its receipt.
Done {
/// The artifact's sha.
sha: String,
/// The decision fragment's sha, when the typ has one (a kernel).
fragment: Option<String>,
},
/// Not run, for a named reason.
Refused {
/// The reason, verbatim.
why: String,
},
}
impl StepState {
/// The one-word state a robot reads.
#[must_use]
pub fn word(&self) -> &'static str {
match self {
StepState::Queued => "queued",
StepState::Running { .. } => "running",
StepState::Cached { .. } => "cached",
StepState::Done { .. } => "done",
StepState::Refused { .. } => "refused",
}
}
/// The line on screen.
#[must_use]
pub fn line(&self) -> String {
match self {
StepState::Queued => "köad".to_string(),
StepState::Running { image } if image.is_empty() => {
"kör — ingen toolchain-bild deklarerad, bygget tar VÄRDENS miljö".to_string()
}
StepState::Running { image } => format!("kör i container {image}"),
StepState::Cached { key } => format!("cachad ur ledgern {}", short(key)),
StepState::Done { sha, fragment } => match fragment {
Some(f) => format!("klar · kvitto sha {} · fragment {}", short(sha), short(f)),
None => format!("klar · kvitto sha {}", short(sha)),
},
StepState::Refused { why } => format!("vägrad: {why}"),
}
}
/// Whether the step is settled — nothing more will happen to it.
#[must_use]
pub fn settled(&self) -> bool {
!matches!(self, StepState::Queued | StepState::Running { .. })
}
fn colour(&self) -> Color32 {
match self {
StepState::Queued => Color32::from_rgb(0x70, 0x70, 0x78),
StepState::Running { .. } => AMBER,
StepState::Cached { .. } => Color32::from_rgb(0x2e, 0x6e, 0xa6),
StepState::Done { .. } => TEAL,
StepState::Refused { .. } => RED,
}
}
}
/// **The honest expectation for a step**, drawn BEFORE it runs. Two figures — cold
/// and cached — and the SOURCE they come from, because an expectation without a
/// source reads as a measurement.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Expectation {
/// What a cold build costs.
pub cold: String,
/// What a ledger hit costs.
pub cached: String,
/// Where the figures come from.
pub source: String,
}
impl Expectation {
/// **By typ.** A kernel is a bounded-container compile (minutes cold); a ledger
/// hit is a pin verification (~145 s per the plan's wave-3 line). A cargo variant
/// in the toolchain container: gunnar-cli measured 5 min 23 s on 2026-08-26.
#[must_use]
pub fn for_typ(typ: ArtifactKind) -> Self {
match typ {
ArtifactKind::Kernel => Expectation {
cold: "minuter — kärnkompilering i toolchain-containern".into(),
cached: "~145 s — pin-träff i ledgern, verifierad (bzImage-sha + fragment)".into(),
source: "förväntan ur .nornir/build-thing-model-plan.md (våg 3), ingen mätning".into(),
},
ArtifactKind::Exe | ArtifactKind::Service | ArtifactKind::Lib | ArtifactKind::Wasm => Expectation {
cold: "minuter — cargo-bygge i toolchain-containern (gunnar-cli: 5 min 23 s)".into(),
cached: "sekunder — ledger-träff på källdigest".into(),
source: "förväntan; gunnar-cli mätt 2026-08-26 (gunnar lane 2), ingen mätning av detta steg".into(),
},
ArtifactKind::Container | ArtifactKind::Vm | ArtifactKind::Iso | ArtifactKind::Installer => Expectation {
cold: "minuter — komponering i container (draupnir)".into(),
cached: "sekunder — ledger-träff".into(),
source: "förväntan; ingen mätning av detta steg".into(),
},
// MÄTT, inte gissat: facett `cargo xtask publish-pip` bygger hjulet i
// den pinnade toolchain-imagen med nätet av. `python -m build` på det
// paketet är sekunder; kostnaden ligger i imagen när den är kall.
// `source` säger vilket av de två talen som är mätt, för det är hela
// skillnaden mellan en förväntan och en siffra.
ArtifactKind::Package => Expectation {
cold: "minuter — toolchain-imagen byggs reproducerbart först".into(),
cached: "sekunder — paketverktyget i den pinnade imagen, nät av".into(),
source: "förväntan; ingen mätning av detta steg".into(),
},
}
}
fn json(&self) -> Value {
json!({ "cold": self.cold, "cached": self.cached, "source": self.source })
}
}
/// **One step of the DAG.**
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Step {
/// `repo/variant` for a donor; the variant name for the consumer.
pub coordinate: String,
/// Donor or consumer.
pub role: StepRole,
/// The typ the step produces.
pub typ: ArtifactKind,
/// The flags this step runs with — the scoped `--opt` line for a donor, the
/// consumer's own flags for the consumer.
pub flags: Vec<String>,
/// Its state.
pub state: StepState,
/// Wall time so far, milliseconds — 0 until it starts.
pub elapsed_ms: u64,
/// The expectation, before the run.
pub expect: Expectation,
/// **The PLANNER's own line for this step**, verbatim — what
/// `dwarves buildthing … --list --build <path>` prints on the prompt
/// (`dwarves::build_steps::describe_plan`): where the donor checkout is, which
/// rule found it, the routed `--opt` line, and for a kernel what the pin check
/// says would happen, decision fragment and all.
///
/// This is law B made comparable rather than asserted: the two doors do not
/// merely claim to plan the same chain, they hand back the same SENTENCE,
/// because the sentence is written once. `None` for a step nobody planned —
/// a fixture, or the pre-click staging drawn before the forge has answered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<String>,
}
impl Step {
/// A queued step.
#[must_use]
pub fn queued(
coordinate: impl Into<String>,
role: StepRole,
typ: ArtifactKind,
flags: Vec<String>,
) -> Self {
Step {
coordinate: coordinate.into(),
role,
typ,
flags,
state: StepState::Queued,
elapsed_ms: 0,
expect: Expectation::for_typ(typ),
plan: None,
}
}
/// The same step, wearing the planner's line for it.
#[must_use]
pub fn planned(mut self, line: impl Into<String>) -> Self {
self.plan = Some(line.into());
self
}
fn json(&self) -> Value {
let detail = match &self.state {
StepState::Queued => Value::Null,
StepState::Running { image } => json!({ "image": image }),
StepState::Cached { key } => json!({ "key": key }),
StepState::Done { sha, fragment } => json!({ "sha": sha, "fragment": fragment }),
StepState::Refused { why } => json!({ "why": why }),
};
json!({
"coordinate": self.coordinate,
"role": self.role.word(),
"typ": self.typ.as_str(),
"flags": self.flags,
"state": self.state.word(),
"line": self.state.line(),
"detail": detail,
"elapsed_ms": self.elapsed_ms,
"expect": self.expect.json(),
"plan": self.plan,
})
}
}
/// **The chained build in flight** — the pane's half of
/// [`dwarves::build_steps::run_build_chain_watched`].
///
/// One plain thread, not a fan-out: this is a single job, and concurrency in this
/// constellation is `gatling` (LAW 3 — rayon is forbidden). The thread owns the
/// chain; the pane owns this, drains the channel once a frame and never blocks. The
/// runner is the ONE the CLI runs — the same registry, the same runa, the same
/// `assemble_cell` underneath — so the two doors cannot build different things; the
/// only thing that differs is that this door reads the run while it happens.
pub struct ChainRun {
rx: std::sync::mpsc::Receiver<StepReport>,
/// Every report seen so far, one per coordinate, in the order they first
/// appeared. Kept because the channel is drained: a state that arrived three
/// frames ago must still be on screen this frame.
seen: Vec<StepReport>,
/// Set by the worker when the chain has ended, whatever way it ended.
done: Arc<AtomicBool>,
/// The chain's own refusal — `run_build_chain` bailing before a single step
/// ran (a donor checkout that is not where the edge says, a set that will not
/// validate). Taken by the first [`poll`](Self::poll) that sees the run end.
outcome: Arc<Mutex<Option<Result<(), String>>>>,
/// Which step a chain-level refusal is ABOUT — the first coordinate the plan
/// named. `run_build_chain` bails with one sentence and no step; a console row
/// needs a coordinate to wear it, and the refusals it gives ("step X: …", a
/// donor that will not read) are all about the step it never got to run.
refusal_on: String,
/// The refusal, once, for the caller that turns it into the pane's note.
note: Option<String>,
}
impl std::fmt::Debug for ChainRun {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChainRun")
.field("seen", &self.seen.len())
.field("done", &self.is_done())
.finish()
}
}
impl ChainRun {
/// **Spawn a chain** — `run` does the blocking work on the worker thread and
/// answers `Ok` when the chain ran (green or red per step) or `Err` with the
/// named reason when it never started.
///
/// `run` is handed the [`dwarves::build_steps::ChainWatch`] to give the runner:
/// every start and settle it reports lands in this channel.
pub fn spawn<F>(refusal_on: impl Into<String>, run: F) -> Self
where
F: FnOnce(dwarves::build_steps::ChainWatch) -> Result<(), String> + Send + 'static,
{
let (tx, rx) = std::sync::mpsc::channel::<StepReport>();
let done = Arc::new(AtomicBool::new(false));
let outcome = Arc::new(Mutex::new(None));
let (d, o) = (done.clone(), outcome.clone());
std::thread::Builder::new()
.name("dwarves-bygg-kant".to_string())
.spawn(move || {
let watch: dwarves::build_steps::ChainWatch = Arc::new(move |p| {
// A send that fails means the pane is gone; the chain keeps
// going (a half-built kernel helps nobody) and stops reporting.
let _ = tx.send(StepReport::from_progress(&p));
});
let r = run(watch);
*o.lock().expect("chain outcome") = Some(r);
d.store(true, Ordering::SeqCst);
})
.expect("spawn the chain worker");
ChainRun {
rx,
seen: Vec::new(),
done,
outcome,
refusal_on: refusal_on.into(),
note: None,
}
}
/// Whether the chain has ended.
#[must_use]
pub fn is_done(&self) -> bool {
self.done.load(Ordering::SeqCst)
}
/// The chain's own refusal, once — for the pane's note.
pub fn take_note(&mut self) -> Option<String> {
self.note.take()
}
/// **Drain what the runner has said and answer every step's state.** Called
/// once a frame; the console applies the answer to the plan.
///
/// A chain that never started is folded in here: `run_build_chain` bails with
/// ONE sentence and no step, and a console left with every row `köad` after the
/// run has ended is the silent no-op this whole lane exists to remove. The
/// sentence lands on the step it is about, refused BY NAME.
pub fn poll(&mut self) -> Vec<StepReport> {
while let Ok(r) = self.rx.try_recv() {
match self.seen.iter_mut().find(|s| s.coordinate == r.coordinate) {
Some(prev) => *prev = r,
None => self.seen.push(r),
}
}
if self.is_done() {
if let Some(Err(why)) = self.outcome.lock().expect("chain outcome").take() {
self.note = Some(why.clone());
let refused = StepReport {
coordinate: self.refusal_on.clone(),
state: StepState::Refused { why },
elapsed_ms: 0,
};
match self
.seen
.iter_mut()
.find(|s| s.coordinate == refused.coordinate)
{
Some(prev) => *prev = refused,
None => self.seen.push(refused),
}
}
}
self.seen.clone()
}
}
/// **A per-step report a runner hands back** — what [`crate::build_scene::Forge::steps`]
/// answers while a chained build runs. Keyed by coordinate; the console applies it to
/// its planned steps and never invents a step the plan did not have.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StepReport {
/// Which step.
pub coordinate: String,
/// Its state now.
pub state: StepState,
/// Wall time so far.
pub elapsed_ms: u64,
}
impl StepReport {
/// **The runner's word for a step, in the console's vocabulary.**
///
/// The mapping is the whole honesty of this console, so it is spelled here and
/// nowhere else:
///
/// | runner | console | why |
/// |---|---|---|
/// | `Started { image: Some }` | `kör i container <image@digest>` | the DECLARED image, digest and all |
/// | `Started { image: None }` | `kör — ingen toolchain-bild deklarerad …` | blocker B1: that build takes the host's env |
/// | `ByPin` | `cachad ur ledgern <nyckel>` | nothing was built; the key names WHERE it was read |
/// | `Built` | `klar · kvitto sha … · fragment …` | the receipt's own shas, never invented |
/// | `Failed` / `Skipped` | `vägrad: <namngiven orsak>` | the step's own sentence, verbatim |
///
/// The `Cached` key spells the pin's own path and fragment rather than a bare
/// hash: `cachad ur ledgern` with an opaque key tells an operator that
/// something was reused and not what, and the pin IS the thing that was reused.
#[must_use]
pub fn from_progress(p: &dwarves::build_steps::StepProgress) -> Self {
use dwarves::build_steps::{StepHow, StepProgress};
match p {
StepProgress::Started {
coordinate, image, ..
} => StepReport {
coordinate: coordinate.clone(),
state: StepState::Running {
image: image.clone().unwrap_or_default(),
},
elapsed_ms: 0,
},
StepProgress::Settled(o) => StepReport {
coordinate: o.coordinate.clone(),
elapsed_ms: o.elapsed.as_millis() as u64,
state: match &o.how {
StepHow::ByPin {
from,
fragment_sha256,
..
} => StepState::Cached {
key: format!("{} · fragment {fragment_sha256}", from.display()),
},
StepHow::Built {
fragment_sha256,
artifact_sha256,
} => StepState::Done {
sha: artifact_sha256
.clone()
.unwrap_or_else(|| "— (kvittot namnger ingen)".to_string()),
fragment: fragment_sha256.clone(),
},
StepHow::Failed(why) => StepState::Refused { why: why.clone() },
StepHow::Skipped { upstream } => StepState::Refused {
why: format!("aldrig försökt — steget {upstream} gick rött"),
},
},
},
}
}
}
/// **The DAG a `BYGG <kant>` plans**: the CLI line it equals and the steps in the
/// order runa runs them — every donor first, the consumer last.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StepPlan {
/// The product word the CLI takes (`dwarves buildthing <product>`), when known.
pub product: Option<String>,
/// The consuming variant.
pub variant: String,
/// The edge's node path (`kernel`).
pub edge_path: String,
/// The edge's target coordinate (`tunnr/tunnr-kernel`).
pub coordinate: String,
/// The exact CLI line — what the node menu shows and what this click runs.
pub cli: String,
/// The steps, DAG order.
pub steps: Vec<Step>,
/// **The site values as they stand** — the picked column's declared settings
/// with the operator's edits over them, exactly as `BYGG` sends them. The
/// forge turns the DECLARED ones among them into the consumer step's
/// `--param` line; a donor step carries none (the routed `--opt` line is its
/// whole input). Empty in a fixture, which is why it defaults.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub settings: std::collections::BTreeMap<String, String>,
/// **The `--param` line the consumer step takes** — the DECLARED parameters among
/// [`settings`](Self::settings), read off the cell by the forge's
/// [`Forge::declared_params`](crate::build_scene::Forge::declared_params). It is a
/// FIELD and not something the printed line re-derives, because the operator's typed
/// values are the half of a click that a printed command cannot guess.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<String>,
}
impl StepPlan {
/// **Plan the two-step for one edge**: the donor with the scoped flags, then the
/// consumer with its own. `cli` is [`cli_line`] over the same words.
#[must_use]
// One argument per plan field the row shows; the CLI line is spelled from them.
#[allow(clippy::too_many_arguments)]
pub fn for_edge(
product: Option<&str>,
variant: &str,
variant_typ: ArtifactKind,
edge_path: &str,
coordinate: &str,
edge_typ: ArtifactKind,
scoped: Vec<String>,
consumer_flags: Vec<String>,
) -> Self {
let mut plan = StepPlan {
product: product.map(str::to_string),
variant: variant.to_string(),
edge_path: edge_path.to_string(),
coordinate: coordinate.to_string(),
cli: String::new(),
steps: vec![
Step::queued(coordinate, StepRole::Donor, edge_typ, scoped),
Step::queued(
consumer_coordinate(product, variant),
StepRole::Consumer,
variant_typ,
consumer_flags,
),
],
settings: Default::default(),
params: Vec::new(),
};
// The line is spelled OFF THE PLAN, never beside it — see `respell_cli`.
plan.respell_cli();
plan
}
/// **The plan the RUNNER planned**, in place of the two-step this click staged.
///
/// `for_edge` can only ever name two steps, because a node knows its own edge
/// and nothing else. The chain is a DAG: `plan_build_steps` (a consumer of
/// edda's `BuildDag::derive` since `7881fd7`) follows the donor's own edges
/// transitively, so a donor that is itself a consumer brings its donor along —
/// deepest first. A console still drawing two rows while runa runs four would
/// be the same lie as a fake spinner, so the forge hands the real plan back and
/// the console restages on it.
///
/// `lines` are the planner's own sentences (`describe_plan`), one or two per
/// step; each step takes the one that opens with its position, so the row and
/// the prompt's dry pass carry the same words.
#[must_use]
// One argument per plan field the row shows; the CLI line is spelled from them.
#[allow(clippy::too_many_arguments)]
pub fn from_planned(
product: Option<&str>,
variant: &str,
variant_typ: ArtifactKind,
edge_path: &str,
coordinate: &str,
consumer_flags: Vec<String>,
planned: &[(String, ArtifactKind, Vec<String>)],
lines: &[String],
cli: String,
params: Vec<String>,
) -> Self {
let mut steps: Vec<Step> = Vec::with_capacity(planned.len() + 1);
for (i, (coord, typ, flags)) in planned.iter().enumerate() {
let head = format!("step {}/{} ", i + 1, planned.len());
// The planner's block for this step: its own line, plus the lines under
// it (a kernel's pin verdict) until the next step's opens.
let line = lines.iter().position(|l| l.starts_with(&head)).map(|p| {
let mut block = vec![lines[p].clone()];
block.extend(
lines[p + 1..]
.iter()
.take_while(|l| !l.starts_with("step "))
.map(|l| l.trim().to_string()),
);
block.join(" · ")
});
let step = Step::queued(coord.clone(), StepRole::Donor, *typ, flags.clone());
steps.push(match line {
Some(l) => step.planned(l),
None => step,
});
}
steps.push(Step::queued(
consumer_coordinate(product, variant),
StepRole::Consumer,
variant_typ,
consumer_flags,
));
// The restaged plan keeps the CLICK's own line. The planner's per-step `--opt`
// words are the EXPANDED, routed ones — faithful to what the forge received, and
// NOT what an operator would type, because the prompt expands them again. The
// line beside the button must stay the line a human can paste.
StepPlan {
product: product.map(str::to_string),
variant: variant.to_string(),
edge_path: edge_path.to_string(),
coordinate: coordinate.to_string(),
cli,
steps,
settings: Default::default(),
params,
}
}
/// **The `--opt` line THIS plan sends**, in the tree's order, deduped by first
/// appearance — the consuming step's whole line leads, then each donor's.
///
/// This is the ONE writer of that routing rule (LAW 5). It used to live only inside
/// the forge's `plan_chain`, which is exactly why the printed CLI line could not
/// agree with the click: the line the menu showed was spelled from the EDGE
/// SUBTREE's flags alone, so a cell-level opt-out such as `--opt wireguard=off` —
/// which lives on the consumer's own line, not under the `kärna` node — was sent by
/// the button and missing from the command beside it. A printed command that does
/// not reproduce the click is worse than none, because somebody pastes it.
///
/// Order matters and is not a detail: the kernel grammar applies the line IN ORDER,
/// cascade and contract refusals included, so the same set in another order is not
/// guaranteed to be the same decision.
#[must_use]
pub fn routed_opts(&self) -> Vec<String> {
let mut line: Vec<String> = Vec::new();
let consumer_first = self
.steps
.last()
.into_iter()
.chain(self.steps.iter().rev().skip(1));
for f in consumer_first.flat_map(|s| s.flags.iter()) {
if let Some(o) = f.strip_prefix("--opt ") {
if !line.iter().any(|x| x == o) {
line.push(o.to_string());
}
}
}
line
}
/// **Respell [`cli`](Self::cli) from what this plan actually carries.** Called by
/// every constructor and by anything that edits `params` after the fact, so the
/// printed line and the sent plan cannot be two facts: they are one field derived
/// from the other.
pub fn respell_cli(&mut self) {
self.cli = cli_line(
self.product.as_deref(),
&self.variant,
&self.edge_path,
&self.routed_opts(),
&self.params,
);
}
}
/// **The consumer step's coordinate** — `<product>/<variant>`, the very key
/// `run_build_chain` registers the consuming build under, so a report coming back
/// off the runner lands on the row that planned it. With no product resolved the
/// bare variant is spelled: a coordinate needs a repo, and inventing one would put
/// the runner's reports on a row that does not match.
#[must_use]
pub fn consumer_coordinate(product: Option<&str>, variant: &str) -> String {
match product {
Some(p) => format!("{p}/{variant}"),
None => variant.to_string(),
}
}
/// **The CLI line a `BYGG <kant>` equals** — the SAME line the node menu shows (law:
/// the CLI builds everything ⚒ builds; `flags_of` is the line on the menu):
/// `dwarves buildthing <product> --variant <variant> --build <path> --opt … --param …`.
/// With no product known the placeholder `<produkt>` is spelled, never a guess.
///
/// # It has to REPRODUCE THE CLICK, whole
///
/// `opts` are the BARE `name=value` words (`wireguard=off`), not pre-spelled flags, and
/// they are the plan's routed line — the consumer's own opt-outs first, then the donor's
/// scoped ones. Until 2026-08-27 this took the edge subtree's flags alone, so the line
/// printed under the button was missing every cell-level `--opt` the button was sending:
/// `--opt wireguard=off` sits on the consumer's line, not under the `kärna` node, and it
/// is precisely the word whose absence changes what is COMPILED INTO the medium. A
/// pasted command that quietly builds WireGuard back in is worse than no command at all.
///
/// `params` are the declared site values (`--param admin_key=…`), likewise verbatim: they
/// are the half of a click a reader cannot guess, and without them the printed line is
/// refused at the door for a required parameter that was in fact supplied.
#[must_use]
pub fn cli_line(
product: Option<&str>,
variant: &str,
edge_path: &str,
opts: &[String],
params: &[String],
) -> String {
let mut s = format!(
"dwarves buildthing {} --variant {variant} --build {edge_path}",
product.unwrap_or("<produkt>")
);
for o in opts {
s.push_str(" --opt ");
s.push_str(o);
}
for p in params {
s.push_str(" --param ");
s.push_str(p);
}
s
}
/// **The receipt a `BYGG <kant>` writes** — beside the build receipt and the RUN receipt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EdgeBuildReceipt {
/// The verb, as the button spelled it.
pub verb: String,
/// The edge's coordinate.
pub coordinate: String,
/// The CLI line it equals.
pub cli: String,
/// `running` · `done` · `refused: <why>`.
pub outcome: String,
}
impl EdgeBuildReceipt {
/// As data.
#[must_use]
pub fn state_json(&self) -> Value {
json!({ "verb": self.verb, "coordinate": self.coordinate, "cli": self.cli, "outcome": self.outcome })
}
}
/// **The console.** Holds one plan at a time, the clock since it started, and the
/// receipt.
#[derive(Debug, Default)]
pub struct StepConsole {
plan: Option<StepPlan>,
started: Option<Instant>,
receipt: Option<EdgeBuildReceipt>,
}
impl StepConsole {
/// Empty — nothing planned.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// The plan on screen, if any.
#[must_use]
pub fn plan(&self) -> Option<&StepPlan> {
self.plan.as_ref()
}
/// The receipt, if a verb fired.
#[must_use]
pub fn receipt(&self) -> Option<&EdgeBuildReceipt> {
self.receipt.as_ref()
}
/// **Stage a plan** — the steps queued, the clock not started. This is the
/// BEFORE-the-run picture: expectation, order, CLI line.
pub fn stage(&mut self, plan: StepPlan) {
self.plan = Some(plan);
self.started = None;
self.receipt = None;
}
/// **Stage a plan from data** — the robot's fixture door: a [`StepPlan`] as JSON,
/// states included, so every state renders without a forge.
///
/// # Errors
/// The serde error when the JSON is not a plan.
pub fn stage_json(&mut self, v: &Value) -> Result<(), String> {
let plan: StepPlan = serde_json::from_value(v.clone()).map_err(|e| e.to_string())?;
self.stage(plan);
Ok(())
}
/// **The verb fired**: the clock starts and the receipt is written `running`.
pub fn started(&mut self, verb: &str) {
let Some(p) = &self.plan else { return };
self.started = Some(Instant::now());
self.receipt = Some(EdgeBuildReceipt {
verb: verb.to_string(),
coordinate: p.coordinate.clone(),
cli: p.cli.clone(),
outcome: "running".to_string(),
});
}
/// **The verb was refused before anything ran**: the donor step wears the reason,
/// the consumer is refused because its donor was, the receipt says `refused`.
/// No clock, no spinner.
pub fn refused(&mut self, verb: &str, why: &str) {
let Some(p) = self.plan.as_mut() else { return };
for (i, s) in p.steps.iter_mut().enumerate() {
s.state = StepState::Refused {
why: if i == 0 {
why.to_string()
} else {
format!("donatorn vägrades — {why}")
},
};
}
self.started = None;
self.receipt = Some(EdgeBuildReceipt {
verb: verb.to_string(),
coordinate: p.coordinate.clone(),
cli: p.cli.clone(),
outcome: format!("refused: {why}"),
});
}
/// **Apply a runner's reports** to the planned steps. A report for a coordinate the
/// plan does not have is ignored — the console draws the plan, not the runner's
/// imagination. The receipt settles when every step has.
pub fn apply(&mut self, reports: &[StepReport]) {
let Some(p) = self.plan.as_mut() else { return };
for r in reports {
if let Some(s) = p.steps.iter_mut().find(|s| s.coordinate == r.coordinate) {
s.state = r.state.clone();
s.elapsed_ms = r.elapsed_ms;
}
}
if let Some(rc) = self.receipt.as_mut() {
if p.steps.iter().all(|s| s.state.settled()) {
rc.outcome = match p
.steps
.iter()
.find(|s| matches!(s.state, StepState::Refused { .. }))
{
Some(s) => format!("refused: {}", s.state.line()),
None => "done".to_string(),
};
}
}
}
/// Wall time since the verb fired, milliseconds.
#[must_use]
pub fn elapsed_ms(&self) -> u64 {
self.started
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(0)
}
/// **Draw the console.** Nothing planned ⇒ one weak line saying so.
pub fn ui(&mut self, ui: &mut Ui) {
let Some(p) = &self.plan else {
ui.label(
RichText::new(
"stegkonsol: ingen kant vald — BYGG <kant> i nodens meny planerar DAG:en här",
)
.weak()
.small(),
);
return;
};
let elapsed = self.elapsed_ms();
ui.label(RichText::new("steg — DAG-ordning: donatorn först, sedan konsumenten").strong());
ui.label(RichText::new(&p.cli).monospace().small());
egui::Grid::new("stegkonsol")
.striped(true)
.num_columns(6)
.show(ui, |ui| {
ui.label(RichText::new("#").weak().small());
ui.label(RichText::new("roll").weak().small());
ui.label(RichText::new("koordinat : typ").weak().small());
ui.label(RichText::new("tillstånd").weak().small());
ui.label(RichText::new("tid").weak().small());
ui.label(RichText::new("förväntan (kallt / cachat)").weak().small());
ui.end_row();
for (i, s) in p.steps.iter().enumerate() {
ui.label(RichText::new(format!("{}", i + 1)).monospace());
ui.label(RichText::new(s.role.word()).monospace());
ui.label(
RichText::new(format!("{} : {}", s.coordinate, s.typ.as_str())).monospace(),
);
ui.label(RichText::new(s.state.line()).color(s.state.colour()));
let ms = match s.state {
StepState::Running { .. } => s.elapsed_ms.max(elapsed),
_ => s.elapsed_ms,
};
ui.label(
RichText::new(if ms == 0 {
"—".to_string()
} else {
format!("{:.1}s", ms as f64 / 1000.0)
})
.monospace(),
);
ui.label(
RichText::new(format!("{} / {}", s.expect.cold, s.expect.cached)).small(),
);
ui.end_row();
}
});
let source = p
.steps
.first()
.map(|s| s.expect.source.clone())
.unwrap_or_default();
ui.label(RichText::new(source).weak().small());
// The PLANNER's own lines, verbatim — the very sentences `--list --build`
// prints on the prompt. On screen so the two doors can be read against each
// other by eye and not only by a robot.
if p.steps.iter().any(|s| s.plan.is_some()) {
ui.label(
RichText::new("planen, som torrpasset skriver den")
.weak()
.small(),
);
for s in p.steps.iter().filter_map(|s| s.plan.as_ref()) {
ui.label(RichText::new(s).monospace().small());
}
}
if let Some(r) = &self.receipt {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("kvitto BYGG").monospace().weak());
ui.label(
RichText::new(format!("{} {} · {}", r.verb, r.coordinate, r.outcome))
.monospace()
.small(),
);
});
}
}
/// As data — the plan, every step with its state word, the clock, the receipt.
#[must_use]
pub fn state_json(&self) -> Value {
match &self.plan {
None => Value::Null,
Some(p) => json!({
"product": p.product,
"variant": p.variant,
"edge_path": p.edge_path,
"coordinate": p.coordinate,
"cli": p.cli,
"steps": p.steps.iter().map(Step::json).collect::<Vec<_>>(),
"elapsed_ms": self.elapsed_ms(),
"receipt": self.receipt.as_ref().map(EdgeBuildReceipt::state_json),
}),
}
}
}
fn short(s: &str) -> String {
if s.len() > 12 {
format!("{}…", &s[..12])
} else {
s.to_string()
}
}
const RED: Color32 = Color32::from_rgb(0xc6, 0x28, 0x28);
const TEAL: Color32 = Color32::from_rgb(0x00, 0x69, 0x5c);
const AMBER: Color32 = Color32::from_rgb(0x8d, 0x6e, 0x00);
#[cfg(test)]
mod tests {
use super::*;
/// **The CLI line is the menu's line**: product, variant, `--build <path>`, then the
/// routed `--opt` words and the declared `--param` values, in order.
#[test]
fn the_cli_line_is_spelled_once() {
let opts = vec![
"kernel.preset=nuked".to_string(),
"kernel.unix=off".to_string(),
];
let params = vec!["admin_key=ssh-ed25519 AAAA".to_string()];
assert_eq!(
cli_line(Some("orten"), "orten-installer", "kernel", &opts, ¶ms),
"dwarves buildthing orten --variant orten-installer --build kernel \
--opt kernel.preset=nuked --opt kernel.unix=off --param admin_key=ssh-ed25519 AAAA"
.replace("\n ", " ")
);
assert!(
cli_line(None, "v", "kernel", &[], &[]).starts_with("dwarves buildthing <produkt> "),
"no product ⇒ a placeholder, never a guess"
);
}
/// ★ **THE PRINTED LINE CARRIES THE CELL'S OWN `--opt`, not only the edge subtree's.**
///
/// The bug this guards, measured 2026-08-27: `cli_line` was handed
/// `scoped_flags(n)` — the flags UNDER the clicked edge node — while the button sent
/// a plan whose consumer step carries the whole tree's line. A cell-level opt-out
/// such as `wireguard=off` lives on the consumer's line, so the pane printed a
/// command that, pasted, builds WireGuard back into the medium. `routed_opts` is the
/// one writer of the routing rule now, and the `cli` field is spelled off the plan.
#[test]
fn the_printed_line_reproduces_the_click_including_the_cells_own_opt() {
let plan = StepPlan::for_edge(
Some("gunnar"),
"gunnar-appliance-iso",
ArtifactKind::Iso,
"kernel",
"gunnar/gunnar-kernel",
ArtifactKind::Kernel,
// the donor's scoped line, under the `kärna` node
vec!["--opt kernel.preset=nuked".to_string()],
// the CONSUMER's own line — `--build kernel` is the edge flag, and
// `wireguard=off` is the cell opt-out the old spelling threw away
vec![
"--build kernel".to_string(),
"--opt wireguard=off".to_string(),
],
);
assert!(
plan.cli.contains("--opt wireguard=off"),
"the cell's own opt-out is missing from the printed line: {}",
plan.cli
);
// Consumer first, then the donor — the kernel grammar applies the line IN ORDER.
assert_eq!(
plan.cli,
"dwarves buildthing gunnar --variant gunnar-appliance-iso --build kernel \
--opt wireguard=off --opt kernel.preset=nuked"
.replace("\n ", " "),
"the whole line"
);
assert_eq!(
plan.routed_opts(),
vec![
"wireguard=off".to_string(),
"kernel.preset=nuked".to_string()
]
);
// `--build kernel` is an edge flag, not an `--opt`: it is already spelled as the
// `--build` word, and repeating it as an option would be refused by name.
assert!(!plan.cli.contains("--opt --build"), "{}", plan.cli);
}
/// The operator's typed site values reach the printed line too — a command missing
/// them is refused at the door for a required parameter that WAS supplied.
#[test]
fn the_printed_line_carries_the_declared_params() {
let mut plan = StepPlan::for_edge(
Some("gunnar"),
"gunnar-appliance-iso",
ArtifactKind::Iso,
"kernel",
"gunnar/gunnar-kernel",
ArtifactKind::Kernel,
vec![],
vec![],
);
assert!(
!plan.cli.contains("--param"),
"nothing declared ⇒ no --param at all"
);
plan.params = vec!["site=stockholm-7".to_string()];
plan.respell_cli();
assert!(
plan.cli.ends_with(" --param site=stockholm-7"),
"{}",
plan.cli
);
}
/// **DAG order**: the donor first, the consumer last; a refusal before the run
/// settles both steps with a named reason and never starts the clock.
#[test]
fn a_refused_verb_refuses_every_step_by_name_and_starts_no_clock() {
let plan = StepPlan::for_edge(
Some("orten"),
"orten-installer",
ArtifactKind::Installer,
"kernel",
"tunnr/tunnr-kernel",
ArtifactKind::Kernel,
vec![],
vec![],
);
assert_eq!(plan.steps[0].role, StepRole::Donor);
assert_eq!(plan.steps[1].role, StepRole::Consumer);
let mut c = StepConsole::new();
c.stage(plan);
c.refused("BYGG kärnan", &missing_runner());
let j = c.state_json();
assert_eq!(j["steps"][0]["state"], json!("refused"));
assert!(
j["steps"][0]["line"]
.as_str()
.unwrap()
.contains(MISSING_RUNNER),
"{j}"
);
assert_eq!(j["steps"][1]["state"], json!("refused"));
assert!(j["steps"][1]["line"]
.as_str()
.unwrap()
.contains("donatorn vägrades"));
assert_eq!(j["elapsed_ms"], json!(0));
assert!(
j["receipt"]["outcome"]
.as_str()
.unwrap()
.starts_with("refused: den här smedjan kopplar ingen"),
"{j}"
);
}
/// **Every state has its own word and line**, and a plan round-trips through JSON
/// with its states — the fixture door the robot uses.
#[test]
fn five_states_five_words_and_the_plan_round_trips() {
let states = [
StepState::Queued,
StepState::Running {
image: "nordisk-cargo-toolchain@sha256:abc".into(),
},
StepState::Cached {
key: "0123456789abcdef".into(),
},
StepState::Done {
sha: "fedcba9876543210".into(),
fragment: Some("aaaa".into()),
},
StepState::Refused {
why: "ingen [build.toolchain]".into(),
},
];
let words: Vec<&str> = states.iter().map(StepState::word).collect();
assert_eq!(words, ["queued", "running", "cached", "done", "refused"]);
let mut lines: Vec<String> = states.iter().map(StepState::line).collect();
lines.dedup();
assert_eq!(lines.len(), 5, "five distinguishable lines: {lines:?}");
let mut plan = StepPlan::for_edge(
None,
"v",
ArtifactKind::Iso,
"kernel",
"tunnr/tunnr-kernel",
ArtifactKind::Kernel,
vec![],
vec![],
);
plan.steps[0].state = states[1].clone();
let v = serde_json::to_value(&plan).unwrap();
let mut c = StepConsole::new();
c.stage_json(&v).unwrap();
assert_eq!(c.plan().unwrap(), &plan);
assert!(c.stage_json(&json!({"nonsense": 1})).is_err());
}
/// A runner's report lands on the planned step by coordinate; a stranger is ignored;
/// the receipt settles when every step has.
#[test]
fn reports_apply_by_coordinate_and_settle_the_receipt() {
let plan = StepPlan::for_edge(
Some("orten"),
"v",
ArtifactKind::Iso,
"kernel",
"tunnr/tunnr-kernel",
ArtifactKind::Kernel,
vec![],
vec![],
);
let mut c = StepConsole::new();
c.stage(plan);
c.started("BYGG kärnan");
assert_eq!(c.receipt().unwrap().outcome, "running");
c.apply(&[
StepReport {
coordinate: "tunnr/tunnr-kernel".into(),
state: StepState::Cached { key: "k".into() },
elapsed_ms: 12,
},
StepReport {
coordinate: "nobody/nothing".into(),
state: StepState::Done {
sha: "x".into(),
fragment: None,
},
elapsed_ms: 1,
},
]);
assert_eq!(c.plan().unwrap().steps.len(), 2, "a stranger adds no step");
assert_eq!(
c.receipt().unwrap().outcome,
"running",
"the consumer is still queued"
);
// The consumer's row wears its COORDINATE — `<product>/<variant>`, the key the
// chain runner registers it under. A report keyed by the bare variant name is
// a stranger, which is the whole point of keying by coordinate.
c.apply(&[StepReport {
coordinate: "v".into(),
state: StepState::Done {
sha: "y".into(),
fragment: None,
},
elapsed_ms: 900,
}]);
assert_eq!(
c.receipt().unwrap().outcome,
"running",
"a bare variant name is not the consumer's coordinate"
);
c.apply(&[StepReport {
coordinate: "orten/v".into(),
state: StepState::Done {
sha: "y".into(),
fragment: None,
},
elapsed_ms: 900,
}]);
assert_eq!(c.receipt().unwrap().outcome, "done");
}
}