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

nordisk/dwarves

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, &params),
            "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");
    }
}