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

nordisk/nornir

src/viz/build_tab.rs

//! 🔨 **Build** tab — the build-thing UI (Tier-2 #5/#26), drawn in the viz.
//!
//! Reflects a [`BuildSurface`](crate::build::surface::BuildSurface) into CHOOSABLE
//! rows — one per declared variant, each showing its `(binary × features × profile
//! × target)` axes, its up-front validity, and a **BUILD** button. Pressing BUILD
//! for the selected variant runs the existing `nornir build --attest --seal` path
//! (validate → materialize → `cargo build` → build-provenance attestation →
//! warehouse row → znippy seal) and surfaces the resulting provenance summary
//! (digest, artifact count, sealed?) back onto the card.
//!
//! # Elm-pure, side-work-as-data
//!
//! The observable state lives entirely in [`BuildThing`](crate::build::elm::BuildThing)
//! (the facett Elm pure core: Model / Msg / update / Effect). `draw` is a pure
//! projection + input collector: it emits [`BuildThingMsg`]s and lets
//! [`BuildThing::update`] mutate the model and hand back [`BuildThingEffect`]s. The
//! only side work — actually running the build — is done by a [`BuildRunner`] seam
//! OFF the paint thread; its result is fed back as a `BuildSucceeded` / `BuildFailed`
//! message. Tests inject a mock runner, so the wiring is verified without compiling
//! the world.
//!
//! Demo content: **holger's** declared build-space (embedded · mini · full), the
//! first consumer of build-thing.

use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use eframe::egui::{self};

use crate::build::elm::{BuildThing, BuildThingEffect, BuildThingMsg};
use crate::build::surface::BuildSurface;
use crate::build::variant::BuildVariantSet;
use crate::jobs::JobSink;

use super::facett_theme::{GREEN, RED, Theme, ThemeExt};
use super::test_boot::{BootRunner, BootStatus, BootTarget, TestBootState};

/// The provenance summary a completed build surfaces (the warehouse row's key
/// facts), fed back into the [`BuildThing`] card and shown in the tab.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvenanceSummary {
    /// Lowercase-hex attestation digest.
    pub digest: String,
    /// How many artifacts the build produced.
    pub artifacts: usize,
    /// Whether the attestation was znippy-sealed.
    pub sealed: bool,
    /// A one-line description of the warehouse row the build recorded.
    pub warehouse_row: String,
}

/// Widest the build grid's free-length `features` cell may grow before it wraps.
const FEATURES_COL_MAX_W: f32 = 260.0;

/// The exact `nornir` CLI argv the BUILD button runs for `variant` — the existing
/// `nornir build --variant <v> --attest --seal` path. The single source of truth
/// for the wiring (asserted by tests, used by [`NornirCliRunner`]).
pub fn attest_seal_argv(variant: &str) -> Vec<String> {
    vec![
        "build".to_string(),
        "--variant".to_string(),
        variant.to_string(),
        "--attest".to_string(),
        "--seal".to_string(),
    ]
}

/// The side-work seam: run a build for a variant and return its provenance summary.
/// The real impl shells out to `nornir build --attest --seal`; tests inject a mock
/// so the BUILD wiring is exercised without compiling anything.
pub trait BuildRunner: Send + Sync {
    /// Run `variant`'s build in `repo_root`; `Ok` carries the provenance summary,
    /// `Err` a human reason.
    fn run(&self, repo_root: &Path, variant: &str) -> Result<ProvenanceSummary, String>;
}

/// The production runner: spawns `nornir build --variant <v> --attest --seal --out
/// <tmp>` in the repo root, then reads the written [`Attestation`] JSON. The seal
/// only materializes when the `build-provenance` feature is compiled into the
/// invoked `nornir`; otherwise the attestation is captured but unsealed (honest).
pub struct NornirCliRunner;

impl BuildRunner for NornirCliRunner {
    fn run(&self, repo_root: &Path, variant: &str) -> Result<ProvenanceSummary, String> {
        use std::process::Command;

        let bin = std::env::var("NORNIR_BIN").unwrap_or_else(|_| "nornir".to_string());
        let out = std::env::temp_dir().join(format!("nornir-build-{variant}.attestation.json"));
        let mut args = attest_seal_argv(variant);
        args.push("--out".to_string());
        args.push(out.to_string_lossy().into_owned());

        let status = Command::new(&bin)
            .current_dir(repo_root)
            .args(&args)
            .status()
            .map_err(|e| format!("spawn `{bin} {}`: {e}", args.join(" ")))?;
        if !status.success() {
            return Err(format!(
                "`{bin} build --variant {variant} --attest --seal` exited {status}"
            ));
        }

        let text = std::fs::read_to_string(&out)
            .map_err(|e| format!("read attestation {}: {e}", out.display()))?;
        let att = crate::build::provenance::Attestation::from_json(&text)
            .map_err(|e| format!("parse attestation: {e:#}"))?;
        Ok(ProvenanceSummary {
            digest: att.digest_hex(),
            artifacts: att.record.artifacts.len(),
            sealed: att.is_sealed(),
            warehouse_row: format!(
                "build_provenance: repo={} variant={} outcome={:?} artifacts={}",
                att.record.repo,
                att.record.variant.name,
                att.record.outcome,
                att.record.artifacts.len(),
            ),
        })
    }
}

/// One in-flight build: the variant + the slot a worker thread posts its result to.
struct InFlight {
    variant: String,
    slot: Arc<Mutex<Option<Result<ProvenanceSummary, String>>>>,
}

/// 🔨 The Build tab: the [`BuildThing`] Elm core + the surface it reflects, a
/// pluggable [`BuildRunner`], and the in-flight build slots.
pub struct BuildTabState {
    /// The Elm pure core (all observable state).
    thing: BuildThing,
    /// The reflected surface (choosable rows: binary × features × profile × target).
    surface: BuildSurface,
    /// The repo the builds run in.
    repo_root: PathBuf,
    /// The build side-work seam (real = [`NornirCliRunner`]).
    runner: Arc<dyn BuildRunner>,
    /// Builds started but not yet reported back.
    inflight: Vec<InFlight>,
    /// The provenance summaries of completed builds, by variant (for the warehouse
    /// row line under a Built card).
    provenance: std::collections::BTreeMap<String, ProvenanceSummary>,
    /// 🚀 The **test-boot** jobs manager: pressing a target's test-boot button
    /// spins the built target up as a nornir job with a bidirectional kill
    /// lifecycle (see [`super::test_boot`]).
    test_boot: TestBootState,
    /// Boot-press intents collected by the pure `view` (variant name → boot), drained
    /// and spawned by the live [`Self::draw`]. Interior-mutable so the pure `&self`
    /// view can record them without mutating the model (mirrors the build `pending`).
    pending_boots: RefCell<Vec<String>>,
    /// Kill-press intents (job ids) collected by the pure view, drained by `draw`.
    pending_kills: RefCell<Vec<String>>,
    /// The declared container image builds (kept so a test-boot builds an exact
    /// [`BootTarget::Container`] from the real descriptor, not a lossy projection).
    container_builds: Vec<nornir_build_thing::ContainerBuild>,
    /// The declared VM builds (kept for exact [`BootTarget::Vm`] test-boots).
    vm_builds: Vec<nornir_build_thing::VmBuild>,
    /// Container/VM test-boot intents (target name) collected by the pure view.
    pending_target_boots: RefCell<Vec<String>>,
    /// The active facett palette.
    palette: Theme,
}

impl BuildTabState {
    /// The Build tab over **`repo_root`'s OWN declared build space**, wired to the
    /// real [`NornirCliRunner`].
    ///
    /// # What this used to do, and why it was wrong
    ///
    /// Until 2026-08-01 this seeded the tab from `HOLGER_DEMO` — a hardcoded
    /// `[build]` TOML literal in this file declaring holger's `embedded` / `mini` /
    /// `full`. It was byte-copied from holger's descriptor when holger was
    /// build-thing's first consumer, and then it froze. The consequence: **nornir's
    /// own Build tab never showed nornir's own atoms.** It showed a stale copy of
    /// another repo's, so pressing BUILD shelled `nornir build --variant full` in
    /// nornir's root, where no variant called `full` exists.
    ///
    /// It was also the second declaration of the build space — a build-variant list
    /// written in Rust next to the TOML that is supposed to be the only one. That is
    /// the shape `nornir_build_thing::suite_cli` exists to remove.
    ///
    /// # The fallback is EMPTY and says so
    ///
    /// A repo with no `.nornir/build-variants.toml` (or an unparseable one) yields an
    /// empty surface carrying the reason, never a fixture. An empty Build tab is a
    /// true statement about a repo that declares no build space; a fixture is a false
    /// one about every repo, and it is indistinguishable from a working tab.
    pub fn new(repo_root: PathBuf) -> Self {
        let (set, note) = match BuildVariantSet::discover(&repo_root) {
            Ok(Some(set)) => (set, None),
            Ok(None) => (
                BuildVariantSet::default(),
                Some(format!(
                    "no build space declared — {} does not exist",
                    BuildVariantSet::conventional_path(&repo_root).display()
                )),
            ),
            Err(e) => (
                BuildVariantSet::default(),
                Some(format!(
                    "{} could not be read: {e:#}",
                    BuildVariantSet::conventional_path(&repo_root).display()
                )),
            ),
        };
        if let Some(why) = &note {
            eprintln!("nornir viz 🔨 Build: {why}");
        }
        Self::from_set(repo_root, set, Arc::new(NornirCliRunner))
    }

    /// Build the tab from a `[build]` TOML descriptor + an injected runner — the
    /// TEST seam, and the only caller that may supply a descriptor as a string.
    /// Falls back to an empty surface if the descriptor doesn't parse.
    pub fn from_toml(repo_root: PathBuf, toml: &str, runner: Arc<dyn BuildRunner>) -> Self {
        Self::from_set(
            repo_root,
            BuildVariantSet::from_toml_str(toml).unwrap_or_default(),
            runner,
        )
    }

    /// The ONE constructor both the live path and the test seam route through.
    pub fn from_set(
        repo_root: PathBuf,
        set: BuildVariantSet,
        runner: Arc<dyn BuildRunner>,
    ) -> Self {
        let repo = repo_root
            .file_name()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_else(|| "<repo>".to_string());
        let surface = BuildSurface::from_set(repo.clone(), &set);
        let thing = BuildThing::from_surface(&surface);
        Self {
            thing,
            surface,
            repo_root,
            runner,
            inflight: Vec::new(),
            provenance: std::collections::BTreeMap::new(),
            // No warehouse configured by default → the boot jobs are observable in
            // the tab's state_json but not persisted; the app swaps in a real sink.
            test_boot: TestBootState::new(JobSink::noop(), repo),
            pending_boots: RefCell::new(Vec::new()),
            pending_kills: RefCell::new(Vec::new()),
            container_builds: set.containers.clone(),
            vm_builds: set.vms.clone(),
            pending_target_boots: RefCell::new(Vec::new()),
            palette: Theme::default(),
        }
    }

    /// Point the test-boot jobs at a durable job ledger `sink` (the app wires the
    /// server's `jobs.redb` sink so boot jobs show in the Jobs panel too).
    pub fn set_boot_sink(&mut self, sink: JobSink, workspace: impl Into<String>) {
        self.test_boot = TestBootState::with_runner(self.test_boot_runner(), sink, workspace);
    }

    /// Test-only: swap the boot side-work runner so a headless robot can press
    /// test-boot without spawning a real child process / container / KVM. A
    /// [`super::test_boot::FakeBootRunner`] drives the whole boot lifecycle
    /// deterministically through the same job path the live tab uses.
    #[doc(hidden)]
    pub fn set_boot_runner_for_test(&mut self, runner: Arc<dyn BootRunner>) {
        self.test_boot =
            TestBootState::with_runner(runner, JobSink::noop(), self.surface.repo.clone());
    }

    /// The current boot runner (kept so `set_boot_sink` preserves an injected one).
    fn test_boot_runner(&self) -> Arc<dyn BootRunner> {
        self.test_boot.runner_arc()
    }

    /// Adopt the app-wide palette (C8) so the pane paints in the active theme.
    pub fn set_palette(&mut self, palette: Theme) {
        self.palette = palette;
    }

    /// Test-only: swap the build side-work runner so a headless robot can drive
    /// the BUILD button without spawning a real `nornir build --attest --seal`
    /// subprocess (which would compile the world). A mock runner returns a canned
    /// [`ProvenanceSummary`] instantly, so the full select → BUILD → provenance
    /// wiring is exercised deterministically through the same async spawn/poll
    /// path the live tab uses (`tests/viz_build_robot.rs`).
    #[doc(hidden)]
    pub fn set_runner_for_test(&mut self, runner: Arc<dyn BuildRunner>) {
        self.runner = runner;
    }

    /// The observable state a headless test / the operator reads: the Elm model,
    /// the surface summary, and each completed build's warehouse row. The inherent
    /// method the app's `state_json()` reads; the facett `Facet::state_json` (from
    /// [`impl_facet_via_elm!`], form `custom_state_json`) delegates to the same body
    /// so the deck/matrix and the app agree on the observable surface.
    pub fn state_json(&self) -> serde_json::Value {
        self.observable_json()
    }

    /// The rich observable JSON, factored out so BOTH the inherent [`Self::state_json`]
    /// and the facett `Facet::state_json` bridge return it (one truth, no method-name
    /// ambiguity between the inherent and trait `state_json`).
    fn observable_json(&self) -> serde_json::Value {
        let provenance: serde_json::Map<String, serde_json::Value> = self
            .provenance
            .iter()
            .map(|(k, v)| {
                (
                    k.clone(),
                    serde_json::json!({
                        "digest": v.digest,
                        "artifacts": v.artifacts,
                        "sealed": v.sealed,
                        "warehouse_row": v.warehouse_row,
                    }),
                )
            })
            .collect();
        serde_json::json!({
            "surface": "build",
            "repo": self.surface.repo,
            "variant_count": self.surface.variants.len(),
            "valid_count": self.surface.variants.iter().filter(|v| v.valid).count(),
            "selected": self.thing.state().selected,
            "building": !self.inflight.is_empty(),
            // The Elm model (cards, statuses, log) — the full pure-core state.
            "model": self.thing.state_json(),
            // The reflected choosable rows (binary × features × profile × target).
            "rows": self.surface.state_json()["variants"].clone(),
            "provenance": serde_json::Value::Object(provenance),
            // 🚀 the live test-boot jobs (Exe/Container/Vm) + their status/log.
            "test_boot": self.test_boot.state_json(),
        })
    }

    /// Best-effort path to a built cargo variant's executable — the exe a
    /// [`super::test_boot::BootMode::Exe`] boot spawns. Derived from the variant's
    /// `bin`/`package` under `<repo>/target/<profile>/`. Best-effort because the
    /// real path depends on `CARGO_TARGET_DIR`/profile-dir; the injected fake runner
    /// makes tests independent of it, and a wrong path surfaces as a Failed boot job
    /// (honest), never a panic.
    fn exe_path_for(&self, variant: &str) -> PathBuf {
        let v = self.surface.variants.iter().find(|v| v.name == variant);
        let bin = v
            .and_then(|v| v.bin.clone().or_else(|| v.package.clone()))
            .unwrap_or_else(|| variant.to_string());
        let profile_dir = match v.map(|v| v.profile.as_str()) {
            Some("release") => "release",
            _ => "debug",
        };
        self.repo_root.join("target").join(profile_dir).join(bin)
    }

    /// Build the [`BootTarget`] for a variant's test-boot. A cargo variant boots in
    /// **Exe** mode (spawn the built binary); a declared VM/container target would
    /// select its own mode via [`BootTarget::mode`].
    fn boot_target_for(&self, variant: &str) -> BootTarget {
        BootTarget::Exe {
            name: variant.to_string(),
            exe: self.exe_path_for(variant),
            args: Vec::new(),
        }
    }

    /// Press **test-boot** for a variant: create a boot job spinning the built exe
    /// up (the ONE boot-spawn path; live `draw` and the test helper both call it).
    pub fn press_test_boot(&mut self, variant: &str) -> String {
        let target = self.boot_target_for(variant);
        self.test_boot.boot(target)
    }

    /// Press **test-boot** for a declared container or VM target (by name): boot it
    /// in its own mode (Container via podman, Vm via tunnr). Returns the job id, or
    /// `None` if no such target is declared.
    pub fn press_test_boot_target(&mut self, name: &str) -> Option<String> {
        if let Some(c) = self.container_builds.iter().find(|c| c.name == name) {
            return Some(self.test_boot.boot(BootTarget::Container(c.clone())));
        }
        if let Some(v) = self.vm_builds.iter().find(|v| v.name == name) {
            return Some(self.test_boot.boot(BootTarget::Vm(v.clone())));
        }
        None
    }

    /// Drive the boot-job lifecycle one tick (drain logs, poll each boot both
    /// directions). Called by `draw`; also the test seam.
    pub fn poll_boots(&mut self) {
        self.test_boot.poll();
    }

    /// Kill a running boot job by id (job cancel → kill the boot). The test seam +
    /// the live 🗙 button both route here.
    pub fn kill_boot(&mut self, job_id: &str) -> bool {
        self.test_boot.kill(job_id)
    }

    /// Send one message through the Elm core and run any effects it hands back
    /// (spawning the build off the paint thread). The ONE mutation path.
    fn dispatch(&mut self, msg: BuildThingMsg) {
        for effect in self.thing.update(msg) {
            match effect {
                BuildThingEffect::RunBuild(variant) => self.spawn_build(variant),
            }
        }
    }

    /// Spawn a worker thread that runs the build via the [`BuildRunner`] so the
    /// paint thread never blocks; the result is polled in [`Self::poll`].
    fn spawn_build(&mut self, variant: String) {
        let slot: Arc<Mutex<Option<Result<ProvenanceSummary, String>>>> =
            Arc::new(Mutex::new(None));
        let runner = Arc::clone(&self.runner);
        let repo_root = self.repo_root.clone();
        let worker_slot = Arc::clone(&slot);
        let worker_variant = variant.clone();
        std::thread::spawn(move || {
            let result = runner.run(&repo_root, &worker_variant);
            *worker_slot.lock().unwrap() = Some(result);
        });
        self.inflight.push(InFlight { variant, slot });
    }

    /// Drain any finished builds, feeding their outcome back through the Elm core.
    fn poll(&mut self) {
        let mut done: Vec<(String, Result<ProvenanceSummary, String>)> = Vec::new();
        self.inflight.retain(|f| {
            let ready = f.slot.lock().unwrap().take();
            match ready {
                Some(result) => {
                    done.push((f.variant.clone(), result));
                    false
                }
                None => true,
            }
        });
        for (variant, result) in done {
            match result {
                Ok(summary) => {
                    // EMIT-DOCTRINE: a finished build is a REAL functional signal —
                    // record it as a matrix row (green). A no-`testmatrix` build strips
                    // the call + the detail string entirely (the emit is an inline no-op).
                    nornir_testmatrix::functional_status(
                        "viz-build",
                        &format!("build_{variant}"),
                        true,
                        &format!(
                            "built '{variant}': {} artifact(s), digest {}…, sealed={}",
                            summary.artifacts,
                            summary.digest.chars().take(16).collect::<String>(),
                            summary.sealed,
                        ),
                    );
                    self.dispatch(BuildThingMsg::BuildSucceeded {
                        variant: variant.clone(),
                        digest: summary.digest.clone(),
                        artifacts: summary.artifacts,
                        sealed: summary.sealed,
                    });
                    self.provenance.insert(variant, summary);
                }
                Err(reason) => {
                    // A failed build is a RED functional row (an honest broken surface).
                    nornir_testmatrix::functional_status(
                        "viz-build",
                        &format!("build_{variant}"),
                        false,
                        &format!("build '{variant}' FAILED: {reason}"),
                    );
                    self.dispatch(BuildThingMsg::BuildFailed { variant, reason });
                }
            }
        }
    }

    /// Run a build synchronously through the SAME runner + feedback path the live
    /// tab uses (no worker thread). The test seam — asserts the full BUILD wiring
    /// (select → `nornir build --attest --seal` → provenance card) deterministically.
    pub fn run_build_blocking(&mut self, variant: &str) {
        let effects = self.thing.update(BuildThingMsg::Build(variant.to_string()));
        for effect in effects {
            let BuildThingEffect::RunBuild(v) = effect;
            match self.runner.run(&self.repo_root, &v) {
                Ok(summary) => {
                    self.dispatch(BuildThingMsg::BuildSucceeded {
                        variant: v.clone(),
                        digest: summary.digest.clone(),
                        artifacts: summary.artifacts,
                        sealed: summary.sealed,
                    });
                    self.provenance.insert(v, summary);
                }
                Err(reason) => self.dispatch(BuildThingMsg::BuildFailed { variant: v, reason }),
            }
        }
    }

    /// Paint the tab: the header, one choosable row per variant, and the activity
    /// log. Pure projection — every mutation goes through [`Self::dispatch`].
    ///
    /// This is the LIVE path (used by the app's tab dispatch): it first drains any
    /// finished builds ([`Self::poll`]) and requests a repaint while one is running,
    /// then runs the pure [`Elm::view`](facett_core::Elm::view) to collect the frame's
    /// `Msg`s and dispatches them — running their [`BuildThingEffect`]s (the real
    /// `spawn_build`). The facett [`Facet::ui`](facett_core::Facet::ui) bridge (from
    /// [`impl_facet_via_elm!`]) drives the SAME `view` but applies `update` without
    /// running effects, so a headless deck/harness render never spawns a real build.
    pub fn draw(&mut self, ui: &mut egui::Ui) {
        // Feed back any completed builds first, then poll the live boot jobs both
        // directions (drain logs, detect a boot that died on its own → job
        // terminates), and request a repaint while a build OR a boot is running.
        self.poll();
        self.poll_boots();
        if !self.inflight.is_empty() || self.test_boot.any_running() {
            ui.ctx().request_repaint();
        }
        // The pure Elm view paints + emits the frame's Msgs; dispatch runs them (and
        // their effects — the real build spawn) — the ONE live mutation path.
        for msg in <Self as facett_core::Elm>::view(self, ui) {
            self.dispatch(msg);
        }
        // Drain the boot/kill presses the pure view recorded and act on them (the
        // live-only side work — a headless deck render never drains these, so it
        // never spawns a real boot).
        let boots: Vec<String> = self.pending_boots.borrow_mut().drain(..).collect();
        for variant in boots {
            self.press_test_boot(&variant);
        }
        let target_boots: Vec<String> = self.pending_target_boots.borrow_mut().drain(..).collect();
        for name in target_boots {
            self.press_test_boot_target(&name);
        }
        let kills: Vec<String> = self.pending_kills.borrow_mut().drain(..).collect();
        for job_id in kills {
            self.kill_boot(&job_id);
        }
    }

    /// The pure FC-9 view of the Build tab: paints the header, one choosable row per
    /// variant, and the activity log, and RETURNS the [`BuildThingMsg`]s the user's
    /// interactions produced — it never mutates the model or does I/O. Kept as the
    /// [`Elm::view`](facett_core::Elm::view) impl below; [`Self::draw`] (live) and the
    /// facett `Facet::ui` bridge both drive it.
    fn view_pure(&self, ui: &mut egui::Ui) -> Vec<BuildThingMsg> {
        ui.heading(format!("🔨 Build — {}", self.surface.repo));
        ui.label(
            "Choose a build target (binary × features × profile × target), then press \
             BUILD to run `nornir build --attest --seal` and record its provenance.",
        );
        ui.add_space(6.0);

        // Collect messages during the render, apply after (keep the borrow clean).
        let mut pending: Vec<BuildThingMsg> = Vec::new();
        let selected = self.thing.state().selected.clone();

        egui::ScrollArea::vertical()
            .auto_shrink([false, false])
            .show(ui, |ui| {
                egui::Grid::new("build_variants")
                    .num_columns(6)
                    .striped(true)
                    .spacing([14.0, 8.0])
                    .show(ui, |ui| {
                        ui.strong("target");
                        ui.strong("binary");
                        ui.strong("features");
                        ui.strong("profile");
                        ui.strong("triple");
                        ui.strong("status / build");
                        ui.end_row();

                        for v in &self.surface.variants {
                            let is_sel = selected.as_deref() == Some(v.name.as_str());
                            if ui.selectable_label(is_sel, v.name.as_str()).clicked() {
                                pending.push(BuildThingMsg::Select(v.name.clone()));
                            }
                            ui.monospace(
                                v.bin
                                    .clone()
                                    .or_else(|| v.package.clone())
                                    .unwrap_or_default(),
                            );
                            // The features cell is the one free-length column. Unbounded it
                            // pushed the action column (BUILD / test-boot) off a 1400-px
                            // surface once egui 0.36 measured text a little wider
                            // (2026-10-02, viz_build_robot): cap it and wrap at the
                            // commas, so the actions stay on screen however many
                            // features a variant declares.
                            ui.scope(|ui| {
                                ui.set_max_width(FEATURES_COL_MAX_W);
                                ui.add(
                                    egui::Label::new(
                                        egui::RichText::new(if v.features.is_empty() {
                                            "—".to_string()
                                        } else {
                                            v.features.join(", ")
                                        })
                                        .monospace(),
                                    )
                                    .wrap(),
                                );
                            });
                            ui.monospace(v.profile.clone());
                            ui.monospace(v.target.clone().unwrap_or_else(|| "host".to_string()));

                            ui.horizontal(|ui| {
                                // Status chip from the Elm card.
                                let card =
                                    self.thing.state().cards.iter().find(|c| c.name == v.name);
                                self.draw_status(ui, card);
                                // The BUILD button — disabled (with the reason) when invalid.
                                let building = self.inflight.iter().any(|f| f.variant == v.name);
                                let enabled = v.valid && !building;
                                // The button text carries the variant name so each
                                // row's BUILD action is a UNIQUELY-addressable atom (no
                                // "BUILD" label collision) — a headless robot can drive
                                // exactly one target's build via `click_label("BUILD full")`.
                                let btn = egui::Button::new(
                                    egui::RichText::new(format!("BUILD {}", v.name)).strong(),
                                );
                                if ui.add_enabled(enabled, btn).clicked() {
                                    pending.push(BuildThingMsg::Build(v.name.clone()));
                                }
                                // 🚀 TEST-BOOT — spin the built target up as a nornir job.
                                // Enabled once the variant is built (a boot needs an
                                // artifact). Uniquely labelled per variant so a robot can
                                // drive exactly one (`click_label("test-boot full")`).
                                let is_built = matches!(
                                    self.thing
                                        .state()
                                        .cards
                                        .iter()
                                        .find(|c| c.name == v.name)
                                        .map(|c| &c.status),
                                    Some(crate::build::elm::CardStatus::Built { .. })
                                );
                                let boot_btn = egui::Button::new(egui::RichText::new(format!(
                                    "🚀 test-boot {}",
                                    v.name
                                )));
                                if ui.add_enabled(is_built, boot_btn).clicked() {
                                    self.pending_boots.borrow_mut().push(v.name.clone());
                                }
                                if !v.valid {
                                    if let Some(reason) = &v.invalid_reason {
                                        ui.colored_label(RED, format!("⚠ {reason}"));
                                    }
                                }
                            });
                            ui.end_row();
                        }
                    });

                // The materialized command line for the selected target + its warehouse row.
                if let Some(sel) = &selected {
                    if let Some(v) = self.surface.variants.iter().find(|v| &v.name == sel) {
                        ui.add_space(8.0);
                        ui.label(egui::RichText::new("selected command").weak());
                        ui.code(v.command_line.as_str());
                    }
                    if let Some(p) = self.provenance.get(sel) {
                        ui.label(
                            egui::RichText::new(&p.warehouse_row)
                                .color(GREEN)
                                .monospace(),
                        );
                    }
                }

                // 🚀 Container / VM boot targets — each declared image/VM gets a
                // test-boot button that spins it up in its own mode (Container via
                // podman, Vm via tunnr). Uniquely labelled per target for robot drives.
                if !self.surface.containers.is_empty() || !self.surface.vms.is_empty() {
                    ui.add_space(8.0);
                    ui.separator();
                    ui.label(egui::RichText::new("bootable targets (container / vm)").weak());
                    for c in &self.surface.containers {
                        ui.horizontal(|ui| {
                            ui.monospace(format!("📦 {} [{}]", c.name, c.format));
                            let btn = egui::Button::new(egui::RichText::new(format!(
                                "🚀 test-boot {}",
                                c.name
                            )));
                            if ui.add_enabled(c.valid, btn).clicked() {
                                self.pending_target_boots.borrow_mut().push(c.name.clone());
                            }
                            if let Some(r) = &c.invalid_reason {
                                ui.colored_label(RED, format!("⚠ {r}"));
                            }
                        });
                    }
                    for v in &self.surface.vms {
                        ui.horizontal(|ui| {
                            ui.monospace(format!(
                                "🖥 {} [{}] {}MiB/{}c",
                                v.name, v.format, v.mem_mb, v.cores
                            ));
                            let btn = egui::Button::new(egui::RichText::new(format!(
                                "🚀 test-boot {}",
                                v.name
                            )));
                            if ui.add_enabled(v.valid, btn).clicked() {
                                self.pending_target_boots.borrow_mut().push(v.name.clone());
                            }
                            if let Some(r) = &v.invalid_reason {
                                ui.colored_label(RED, format!("⚠ {r}"));
                            }
                        });
                    }
                }

                // Activity log (newest last).
                let log = &self.thing.state().log;
                if !log.is_empty() {
                    ui.add_space(8.0);
                    ui.separator();
                    ui.label(egui::RichText::new("activity").weak());
                    for line in log.iter().rev().take(8) {
                        ui.monospace(line.as_str());
                    }
                }

                // 🚀 Running / recent test-boot jobs — one row per boot, with its live
                // status, a 🗙 kill button (job cancel → kill the boot), and a log tail.
                let boot_jobs = self.test_boot.jobs();
                if !boot_jobs.is_empty() {
                    ui.add_space(8.0);
                    ui.separator();
                    ui.label(egui::RichText::new("🚀 test-boot jobs").strong());
                    for job in boot_jobs {
                        ui.horizontal(|ui| {
                            let color = match &job.status {
                                BootStatus::BootedOk => self.palette.status_color("ok"),
                                BootStatus::Running | BootStatus::Booting => {
                                    self.palette.status_color("running")
                                }
                                BootStatus::Failed(_) => self.palette.status_color("error"),
                                BootStatus::Killed => RED,
                            };
                            ui.colored_label(color, format!("● {}", job.status.as_str()));
                            ui.monospace(format!("{} [{}]", job.target, job.mode.as_str()));
                            if !job.status.is_terminal() {
                                let kill = egui::Button::new(
                                    egui::RichText::new(format!("🗙 kill {}", job.target))
                                        .color(RED),
                                );
                                if ui.add(kill).clicked() {
                                    self.pending_kills.borrow_mut().push(job.job_id.clone());
                                }
                            }
                        });
                        // The boot log tail (child stdout/stderr / serial console).
                        for line in job.log.iter().rev().take(4).rev() {
                            ui.monospace(format!("  {line}"));
                        }
                    }
                }
            });

        pending
    }

    /// Paint the per-card status chip (idle / building / built digest / failed).
    fn draw_status(&self, ui: &mut egui::Ui, card: Option<&crate::build::elm::VariantCard>) {
        use crate::build::elm::CardStatus;
        match card.map(|c| &c.status) {
            Some(CardStatus::Idle) | None => {
                ui.weak("idle");
            }
            Some(CardStatus::Building) => {
                ui.colored_label(self.palette.status_color("running"), "building…");
            }
            Some(CardStatus::Built {
                digest,
                artifacts,
                sealed,
            }) => {
                let seal = if *sealed { " 🔒" } else { "" };
                ui.colored_label(
                    self.palette.status_color("ok"),
                    format!("built · {artifacts} art · {digest}…{seal}"),
                );
            }
            Some(CardStatus::Failed { reason }) => {
                ui.colored_label(
                    self.palette.status_color("error"),
                    format!("failed: {reason}"),
                );
            }
        }
    }
}

/// **The facett `Elm` contract for the Build tab.** nornir's dep-light
/// `nornir-build-thing` crate holds the pure `Model`/`Msg`/`update`/`Effect` core
/// ([`BuildThing`]) but cannot pull egui, so the egui `view` — and thus the
/// `facett_core::Elm` impl + the `Facet` bridge — live HERE on the viz side (exactly
/// as the crate's own docs prescribe). With this, `Tab::Build` renders through the
/// same facett `Facet` contract as nornir's ~35 other embedded facett components and
/// can be driven headless by `facett_core::harness` / asserted via `state_json`.
///
/// - `Model` = [`BuildThingModel`](crate::build::elm::BuildThingModel) (the whole
///   pure-core observable state);
/// - `Msg` = [`BuildThingMsg`] / `Effect` = [`BuildThingEffect`];
/// - `update` is the pure mutation path (delegates to the core `BuildThing::update`)
///   and returns the effects as DATA — the live [`BuildTabState::draw`] runs them
///   (`spawn_build`), while the macro-generated `Facet::ui` applies them without
///   side work so a headless render never spawns a real build.
impl facett_core::Elm for BuildTabState {
    type Model = crate::build::elm::BuildThingModel;
    type Msg = BuildThingMsg;
    type Effect = BuildThingEffect;

    fn title(&self) -> &str {
        self.thing.title()
    }

    fn state(&self) -> &Self::Model {
        self.thing.state()
    }

    fn update(&mut self, msg: BuildThingMsg) -> Vec<BuildThingEffect> {
        self.thing.update(msg)
    }

    fn view(&self, ui: &mut egui::Ui) -> Vec<BuildThingMsg> {
        self.view_pure(ui)
    }
}

// Writes `impl Facet for BuildTabState` (title + the FC-9 view/update `ui` loop),
// with the RICH `state_json` (surface + provenance + rows) rather than the plain
// model-only serde default — the same observable JSON the app's `state_json()` reads.
facett_core::impl_facet_via_elm!(BuildTabState, custom_state_json, {
    fn state_json(&self) -> facett_core::elm::__macro_deps::serde_json::Value {
        self.observable_json()
    }
});

#[cfg(test)]
mod tests {
    use super::*;

    /// A mock runner: records every (repo_root, variant) call and returns a canned
    /// provenance summary — so the BUILD wiring is tested WITHOUT compiling the world.
    #[derive(Default)]
    struct MockRunner {
        calls: Mutex<Vec<(PathBuf, String)>>,
        fail: bool,
    }
    impl BuildRunner for MockRunner {
        fn run(&self, repo_root: &Path, variant: &str) -> Result<ProvenanceSummary, String> {
            self.calls
                .lock()
                .unwrap()
                .push((repo_root.to_path_buf(), variant.to_string()));
            if self.fail {
                return Err("cargo exited 101".to_string());
            }
            Ok(ProvenanceSummary {
                digest: "abcdef0123456789 feedface".replace(' ', ""),
                artifacts: 1,
                sealed: true,
                warehouse_row: format!(
                    "build_provenance: variant={variant} outcome=Success artifacts=1"
                ),
            })
        }
    }

    /// A THREE-VARIANT descriptor for the tab's own unit tests, kept deliberately
    /// generic (`embedded` / `mini` / `full`) so the tests exercise the reflection
    /// and the BUILD wiring without pinning them to any repo's real atom names.
    ///
    /// ⚠ TEST-ONLY. This is what used to be `HOLGER_DEMO` at module scope, where it
    /// seeded the LIVE `BuildTabState::new` and made nornir's Build tab render
    /// holger's frozen build space instead of nornir's own. The live path now
    /// discovers the repo's real declaration; a build space written in Rust exists
    /// nowhere outside this `#[cfg(test)]` module.
    const DEMO_FIXTURE: &str = r#"
[build]
offers = ["sign", "testmatrix", "served", "gui"]

[[build.variant]]
name = "embedded"
package = "holger-server-lib"
features = ["sign"]
no_default_features = true
profile = "release"

[[build.variant]]
name = "mini"
package = "holger-server-cli"
bin = "holger-server"
no_default_features = true
profile = "release"

[[build.variant]]
name = "full"
package = "holger-server-cli"
bin = "holger-server"
profile = "release"
"#;

    fn tab_with(runner: Arc<dyn BuildRunner>) -> BuildTabState {
        BuildTabState::from_toml(PathBuf::from("/tmp/holger"), DEMO_FIXTURE, runner)
    }

    /// ★ RED-WHEN-BROKEN: the live Build tab reflects **THIS repo's** declared
    /// atoms, not a fixture. Before 2026-08-01 it rendered a frozen copy of
    /// holger's `embedded`/`mini`/`full` in every repo, so the assertion that
    /// would have caught it is precisely "the names on the cards are the names in
    /// the file". Reads the expected set from the descriptor rather than
    /// hardcoding it, so it cannot rot into a second declaration of its own.
    #[test]
    fn the_live_build_tab_shows_this_repos_own_atoms() {
        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf();
        let declared = BuildVariantSet::discover(&root)
            .expect("read .nornir/build-variants.toml")
            .expect("nornir declares a build space");
        let want: Vec<&str> = declared.variants.iter().map(|v| v.name.as_str()).collect();
        assert!(
            want.len() >= 2,
            "the declaration must carry more than one atom for this guard to mean anything"
        );

        let tab = BuildTabState::new(root);
        let got: Vec<&str> = tab
            .surface
            .variants
            .iter()
            .map(|v| v.name.as_str())
            .collect();
        assert_eq!(
            got, want,
            "the Build tab is not showing nornir's declared atoms (it showed holger's \
             frozen `embedded`/`mini`/`full` fixture until 2026-08-01)"
        );
        for stale in ["embedded", "mini", "full"] {
            assert!(
                !got.contains(&stale),
                "the tab still carries the holger demo fixture variant '{stale}'"
            );
        }
    }

    /// The EMPTY fallback is empty and labelled — never a fixture. A repo with no
    /// declaration must produce zero cards, because "here are three variants you do
    /// not have" is worse than "you declare none".
    #[test]
    fn a_repo_with_no_declaration_gets_an_empty_tab_not_a_fixture() {
        let dir = tempfile::tempdir().expect("tempdir");
        let tab = BuildTabState::new(dir.path().to_path_buf());
        assert!(
            tab.surface.variants.is_empty(),
            "a repo declaring no build space got {} card(s): {:?}",
            tab.surface.variants.len(),
            tab.surface
                .variants
                .iter()
                .map(|v| &v.name)
                .collect::<Vec<_>>()
        );
    }

    /// 🚀 **Press test-boot → a nornir job that tracks the boot** — the core of
    /// the ask, asserted through the tab's `state_json` (the surface a robot reads),
    /// with the boot side-work FAKED so no real process spawns. Covers all three
    /// lifecycle facts: a press creates a running boot job; the job tracks the boot
    /// status; and both kill directions (job-kill → boot killed; boot-death → job
    /// terminates) land in `state_json`.
    #[test]
    fn press_test_boot_creates_a_job_that_tracks_boot_status() {
        use crate::viz::test_boot::BootStatus;
        use crate::viz::test_boot::{FakeBootRunner, FakeProc};

        // Build `full` first (test-boot needs a built artifact), then inject a fake
        // boot runner whose proc we keep to drive the lifecycle.
        let mut tab = tab_with(Arc::new(MockRunner::default()));
        tab.run_build_blocking("full");

        let proc = FakeProc::running();
        tab.set_boot_runner_for_test(Arc::new(FakeBootRunner::yielding(proc.clone())));

        // Press test-boot → one boot job appears, kind exe, tracked in state_json.
        let job_id = tab.press_test_boot("full");
        let j = tab.state_json();
        assert_eq!(j["test_boot"]["boot_jobs"].as_array().unwrap().len(), 1);
        assert_eq!(j["test_boot"]["boot_jobs"][0]["mode"], "exe");
        assert_eq!(j["test_boot"]["boot_jobs"][0]["target"], "full");
        assert_eq!(j["test_boot"]["boot_jobs"][0]["status"], "booting");

        // Poll → the job tracks the boot reaching Running.
        tab.poll_boots();
        assert_eq!(
            tab.state_json()["test_boot"]["boot_jobs"][0]["status"],
            "running"
        );
        assert_eq!(tab.state_json()["test_boot"]["running"], 1);

        // Kill the JOB → the boot is killed (bidirectional), job goes terminal.
        assert!(tab.kill_boot(&job_id));
        assert!(
            *proc.killed.lock().unwrap(),
            "the job-kill reached the boot"
        );
        let j = tab.state_json();
        assert_eq!(j["test_boot"]["boot_jobs"][0]["status"], "killed");
        assert_eq!(j["test_boot"]["running"], 0);
        assert_eq!(j["test_boot"]["boot_jobs"][0]["terminal"], true);
        let _ = BootStatus::Booting; // (enum in scope for readers)
    }

    /// The other lifecycle direction: the boot DIES on its own → the job terminates
    /// without any operator kill.
    #[test]
    fn boot_death_terminates_the_job() {
        use crate::viz::test_boot::{BootStatus, FakeBootRunner, FakeProc};
        let mut tab = tab_with(Arc::new(MockRunner::default()));
        tab.run_build_blocking("mini");
        let proc = FakeProc::running();
        tab.set_boot_runner_for_test(Arc::new(FakeBootRunner::yielding(proc.clone())));

        tab.press_test_boot("mini");
        tab.poll_boots();
        assert_eq!(
            tab.state_json()["test_boot"]["boot_jobs"][0]["status"],
            "running"
        );

        // The boot exits cleanly on its own → next poll flips the job to booted_ok.
        *proc.status.lock().unwrap() = BootStatus::BootedOk;
        tab.poll_boots();
        assert_eq!(
            tab.state_json()["test_boot"]["boot_jobs"][0]["status"],
            "booted_ok"
        );
        assert_eq!(tab.state_json()["test_boot"]["running"], 0);
    }

    #[test]
    fn holger_demo_reflects_into_three_choosable_rows() {
        let tab = tab_with(Arc::new(MockRunner::default()));
        assert_eq!(
            tab.surface.variant_names(),
            vec!["embedded", "mini", "full"]
        );
        assert!(tab.surface.variants.iter().all(|v| v.valid));
        let j = tab.state_json();
        assert_eq!(j["surface"], "build");
        assert_eq!(j["variant_count"], 3);
        assert_eq!(j["valid_count"], 3);
        // The rows carry the choosable axes (binary × features × profile × target).
        assert_eq!(j["rows"][0]["name"], "embedded");
        assert_eq!(j["rows"][0]["features"][0], "sign");
    }

    #[test]
    fn build_button_wires_to_nornir_build_attest_seal() {
        // The single source of truth for the BUILD action's argv.
        assert_eq!(
            attest_seal_argv("full"),
            vec!["build", "--variant", "full", "--attest", "--seal"],
        );

        let runner = Arc::new(MockRunner::default());
        let mut tab = tab_with(runner.clone());
        tab.run_build_blocking("full");

        // The runner was invoked exactly once, for `full`, in the repo root.
        let calls = runner.calls.lock().unwrap();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].1, "full");
        assert_eq!(calls[0].0, PathBuf::from("/tmp/holger"));

        // The card ends Built and surfaces the provenance summary + warehouse row.
        let full = tab
            .thing
            .state()
            .cards
            .iter()
            .find(|c| c.name == "full")
            .unwrap();
        assert!(
            matches!(
                &full.status,
                crate::build::elm::CardStatus::Built {
                    artifacts: 1,
                    sealed: true,
                    ..
                }
            ),
            "got {:?}",
            full.status,
        );
        let j = tab.state_json();
        assert_eq!(j["provenance"]["full"]["artifacts"], 1);
        assert_eq!(j["provenance"]["full"]["sealed"], true);
        assert!(
            j["provenance"]["full"]["warehouse_row"]
                .as_str()
                .unwrap()
                .contains("variant=full")
        );
    }

    /// A descriptor whose `bad` variant enables a feature holger doesn't offer, so
    /// the surface reflects it as invalid (with a reason) — the disabled-BUILD row.
    const INVALID_DEMO: &str = r#"
[build]
offers = ["sign"]
[[build.variant]]
name = "good"
package = "holger-server-lib"
features = ["sign"]
profile = "release"
[[build.variant]]
name = "bad"
package = "holger-server-lib"
features = ["ghost"]
profile = "release"
"#;

    #[test]
    fn invalid_variant_never_runs_the_build() {
        let runner = Arc::new(MockRunner::default());
        let mut tab =
            BuildTabState::from_toml(PathBuf::from("/tmp/holger"), INVALID_DEMO, runner.clone());
        // The `bad` variant reflected as invalid with a reason (feature not offered).
        let bad = tab
            .surface
            .variants
            .iter()
            .find(|v| v.name == "bad")
            .unwrap();
        assert!(!bad.valid);
        assert!(
            bad.invalid_reason
                .as_deref()
                .unwrap()
                .contains("not offered")
        );
        // Pressing BUILD on it is refused by the Elm guard → the runner never runs.
        tab.run_build_blocking("bad");
        assert!(
            runner.calls.lock().unwrap().is_empty(),
            "invalid variant must not run"
        );
        let bad_card = tab
            .thing
            .state()
            .cards
            .iter()
            .find(|c| c.name == "bad")
            .unwrap();
        assert_eq!(bad_card.status, crate::build::elm::CardStatus::Idle);
    }

    #[test]
    fn failed_build_marks_the_card_failed() {
        let runner = Arc::new(MockRunner {
            fail: true,
            ..Default::default()
        });
        let mut tab = tab_with(runner);
        tab.run_build_blocking("mini");
        let mini = tab
            .thing
            .state()
            .cards
            .iter()
            .find(|c| c.name == "mini")
            .unwrap();
        assert!(matches!(
            &mini.status,
            crate::build::elm::CardStatus::Failed { .. }
        ));
    }

    /// **The facett `Elm` + `Facet` contract** — drive the Build tab through the
    /// SAME headless harness the ~six other embedded facett components use
    /// (`autonom::facett_probe`): apply a `Msg` through `Elm::update` (the only
    /// mutation path), snapshot the `Model` (FC-3), read the RICH `Facet::state_json`,
    /// and prove `Facet::ui` paints — no window, no GPU. This is what makes
    /// `Tab::Build` "driven/asserted like the others".
    #[test]
    fn drives_through_the_facett_elm_harness() {
        use facett_core::{Facet, harness};
        let mut tab = tab_with(Arc::new(MockRunner::default()));

        // Drive Select("full") through the Elm contract; the snapshot is the pure
        // core Model (BuildThingModel) — `update` is the only way it changed.
        let snap = harness::snapshot_json(
            &mut tab,
            vec![crate::build::elm::BuildThingMsg::Select("full".to_string())],
        );
        assert!(snap.is_object(), "snapshot is the serializable Model");
        assert_eq!(
            snap["selected"],
            serde_json::json!("full"),
            "Select folded into the model"
        );

        // The Facet's rich state_json (surface + rows + model) — the same observable
        // JSON the app reads (custom_state_json bridge, not the plain model default).
        let facet_state = Facet::state_json(&tab);
        assert_eq!(facet_state["surface"], serde_json::json!("build"));
        assert_eq!(facet_state["variant_count"], serde_json::json!(3));
        assert_eq!(facet_state["model"]["selected"], serde_json::json!("full"));

        // The Facet paints headless (proves the egui `view` renders through the bridge).
        let report = harness::headless_render(&mut tab);
        assert!(
            report.drew(),
            "the Build Facet drew tessellated geometry: {report:?}"
        );
    }

    /// Headless render smoke: instantiate the model + draw once, assert no panic and
    /// a non-empty state_json. (The FULL interactive drive — real AccessKit clicks on
    /// the target rows + the per-row BUILD button, asserted against `state_json` — is
    /// the robot-UI test `tests/viz_build_robot.rs`.)
    #[test]
    fn headless_render_does_not_panic_and_emits_state() {
        let mut tab = tab_with(Arc::new(MockRunner::default()));
        let ctx = egui::Context::default();
        // egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
        // deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
        ctx.run_ui(egui::RawInput::default(), |ui| {
            egui::CentralPanel::default().show(ui, |ui| tab.draw(ui));
        }).textures_delta.clear();
        let j = tab.state_json();
        assert!(!j.as_object().unwrap().is_empty());
        assert_eq!(j["variant_count"], 3);
    }
}