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/pipeline.rs

//! The **Pipeline DAG** tab — the forge's pipelines rendered on the REUSED
//! `facett-pipeline` viewer (the same animated, themed DAG viewer knut's Spark
//! pipelines and every other engine render on).
//!
//! It hosts TWO sources on that ONE viewer — "show another engine's pipeline = a new
//! ADAPTER, not a new viewer":
//!
//! * **runa** (the default) — the CI/CD pipeline dwarves AUTHORS as data. A real
//!   committed `*.runa.ron` is loaded through [`dwarves_runa::Pipeline::from_ron_str`] (which
//!   also runs runa's validator — duplicate-stage / unknown-`needs` / DAG-cycle) and
//!   mapped onto facett's engine-agnostic `PipelineModel` by
//!   [`facett_pipeline::RunaPipelineAdapter`]. Stages → nodes, `needs` → DAG edges, the
//!   stage's typed [`dwarves_runa::Step`]s → the node sublabel. Its per-stage status is LIVE:
//!   read back from the run history dwarves historises for jera-dispatched runs
//!   ([`dwarves::store::read_runs`]/[`read_steps`](dwarves::store::read_steps)), else
//!   from the jera ledger's own `CI_BUILD` record for that pipeline, else honestly Idle.
//! * **runs** — the selected pipeline's REAL runs, read from the forge
//!   ([`crate::pipeline_runs::RunsLeg`]): newest first, each run's stages and steps, a
//!   step's log, a `BuildWorkspace` step's units. (This leg was `NOT WIRED` until
//!   2026-10-16; before that it drew a pipeline typed into this file, labelled a live run.)
//!
//! Both sources are `PipelineView`s built by `PipelineView::from_adapter` — no viewer is
//! re-implemented here, and no runa twin of the brokkr view exists.
//!
//! Native-only (alongside the Funnel / Claude / Jobs panes): the wasm web demo keeps the
//! single Forge panel.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use dwarves::brokkr::history::RunStatus;
use facett_core::Facet;
use facett_pipeline::{PipelineView, RunaPipelineAdapter, StageStatus};

// ── the runa leg: a real `*.runa.ron`, lit from the live run state ────────────────

/// Where the per-stage status came from — the honest provenance the gate asserts on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StatusSource {
    /// Per-stage rows from the historised run (`brokkr/steps.jsonl`) of the newest run
    /// of THIS pipeline — the richest signal.
    RunHistory,
    /// No per-stage history, but the jera ledger holds a `CI_BUILD` record for this
    /// pipeline: every stage takes that run-level status.
    JeraLedger,
    /// Nothing has run this pipeline here — every stage is honestly Idle.
    Static,
}

impl StatusSource {
    /// The stable lower-case tag used in `state_json`.
    pub fn key(self) -> &'static str {
        match self {
            StatusSource::RunHistory => "run-history",
            StatusSource::JeraLedger => "jera-ledger",
            StatusSource::Static => "static",
        }
    }
}

/// The repo root the `*.runa.ron` files are discovered under. Ladder:
/// `$DWARVES_REPO_ROOT` → the current directory when it holds a `*.runa.ron` → the
/// dwarves checkout this binary was built from (`dwarves-gui/..`). Never panics.
pub fn runa_repo_root() -> PathBuf {
    if let Some(r) = std::env::var("DWARVES_REPO_ROOT")
        .ok()
        .filter(|r| !r.trim().is_empty())
    {
        return PathBuf::from(r);
    }
    if let Ok(cwd) = std::env::current_dir() {
        if !discover_runa_files(&cwd).is_empty() {
            return cwd;
        }
    }
    // `dwarves-gui/` → the dwarves checkout root (where `dwarves.runa.ron` lives).
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .map(|p| p.to_path_buf())
        .unwrap_or_else(|| PathBuf::from("."))
}

/// Every committed `*.runa.ron` under `root` — the repo root itself plus the
/// conventional `pipelines/` directory — sorted by path so the list is stable.
pub fn discover_runa_files(root: &Path) -> Vec<PathBuf> {
    let mut out: Vec<PathBuf> = Vec::new();
    for dir in [root.to_path_buf(), root.join("pipelines")] {
        let Ok(rd) = std::fs::read_dir(&dir) else {
            continue;
        };
        for e in rd.filter_map(|e| e.ok()) {
            let p = e.path();
            if p.file_name()
                .and_then(|n| n.to_str())
                .map(|n| n.ends_with(".runa.ron"))
                .unwrap_or(false)
            {
                out.push(p);
            }
        }
    }
    out.sort();
    out
}

/// The dwarves warehouse the run history is read from — the SAME ladder the forge's
/// own `store::warehouse_from` walks (`$DWARVES_WAREHOUSE` → `$HOME/.dwarves/warehouse`),
/// never a re-derived path.
fn warehouse_root() -> PathBuf {
    dwarves::store::warehouse_from(
        std::env::var("DWARVES_WAREHOUSE").ok().as_deref(),
        std::env::var("HOME").ok().as_deref(),
        /* system_scope */ false,
    )
}

/// Fold one runa stage's historised step rows into a single [`StageStatus`]: any failed
/// step fails the stage, any still-running step keeps it running, all green is Done, and
/// a stage with no rows has not started (Idle). Pure — the tested core of the live light.
pub fn fold_stage_status(rows: &[RunStatus]) -> StageStatus {
    if rows.is_empty() {
        return StageStatus::Idle;
    }
    if rows.contains(&RunStatus::Failed) {
        return StageStatus::Failed;
    }
    if rows.contains(&RunStatus::Running) {
        return StageStatus::Running;
    }
    if rows.iter().all(|s| *s == RunStatus::Passed) {
        return StageStatus::Done;
    }
    // Anything left is a skipped/never-started mix — honestly not done.
    StageStatus::Idle
}

/// Map a jera ledger status string onto the stage light (the run-level fallback).
/// EXHAUSTIVE on purpose: it used to match on the ledger's status STRING with a
/// `_ => None` catch-all, so a status jera renamed would have read as "no verdict" —
/// silently, forever. Over the shared [`facett_jobview::JobStatus`] the compiler owns
/// that decision instead (E0004 when a variant is added).
fn ledger_status(status: facett_jobview::JobStatus) -> StageStatus {
    use facett_jobview::JobStatus;
    match status {
        JobStatus::Running => StageStatus::Running,
        JobStatus::Done => StageStatus::Done,
        JobStatus::Failed => StageStatus::Failed,
        JobStatus::Queued => StageStatus::Idle,
    }
}

/// The live per-stage status for `pipeline_name`, plus where it came from and the run it
/// reflects. Reads the warehouse run history first (per-stage truth), then the jera
/// ledger (run-level truth), then gives up honestly. Never panics; a missing warehouse /
/// ledger is the [`StatusSource::Static`] case.
fn live_stage_status(
    pipeline_name: &str,
    warehouse: &Path,
    jobs: Option<&dyn crate::jera::JeraJobSource>,
) -> (HashMap<String, StageStatus>, StatusSource, String) {
    // (1) The historised run: newest run of THIS pipeline wins.
    if let Ok(runs) = dwarves::store::read_runs(warehouse) {
        if let Some(run) = runs.iter().rev().find(|r| r.pipeline == pipeline_name) {
            if let Ok(steps) = dwarves::store::read_steps(warehouse, &run.run_id) {
                if !steps.is_empty() {
                    let mut per_stage: HashMap<String, Vec<RunStatus>> = HashMap::new();
                    for row in &steps {
                        per_stage
                            .entry(row.stage.clone())
                            .or_default()
                            .push(row.status);
                    }
                    let map = per_stage
                        .into_iter()
                        .map(|(stage, rows)| (stage, fold_stage_status(&rows)))
                        .collect();
                    return (map, StatusSource::RunHistory, run.run_id.clone());
                }
            }
        }
    }
    // (2) The jera ledger's own `CI_BUILD` record for this pipeline (the run-level light
    // the runa executor opens for every `dwarves run`).
    //
    // **OVER THE SAME SEAM THE JERA TAB USES.** This leg used to call
    // `dwarves_embed::read_ledger_jobs` on a path derived from the UI's own `$HOME`
    // (`$DWARVES_WAREHOUSE`, else `~/.dwarves/...`). `9e82007` took that road away from
    // the Jera tab and this pane kept it, so the shell shipped two panes reading "the
    // ledger" by two different roads — and on any real deployment the guessed one is the
    // wrong file: the served workspace's ledger lives under the SERVER's nornir
    // warehouse, which the installed forge (user `dwarves`, mode `-rw-r--r--`) opens for
    // its process lifetime and the UI's user cannot open for WRITE at all, which is what
    // `JobStore::open` needs even to read. Now both panes come through
    // [`crate::jera::JeraJobSource`], so the file road is taken in exactly one place and
    // only when an operator names it.
    if let Some(src) = jobs {
        // `Rows` is the only answer that carries a verdict. `Empty` / `Absent` /
        // `Failed` all mean "no ledger verdict for this pipeline" HERE — the honest
        // rendering of the difference belongs to the Jera tab, which owns it.
        // `""` = every kind: this pane matches on the ledger's `target` (the pipeline
        // name) across whatever ran, so scoping the read to one class would hide the very
        // verdict it is looking for. The Jera tab is where a class window belongs
        // (`jera::JeraTab::kind_ui`).
        if let crate::jera::JobsRead::Rows(entries) =
            src.read(dwarves_embed::DEFAULT_LEDGER_LIMIT, "")
        {
            if let Some(rec) = entries.iter().find(|r| r.target == pipeline_name) {
                // A run-level verdict applies to every stage the run covered — recorded
                // under the sentinel key the caller expands over the stage names.
                let map = HashMap::from([(UNIFORM_KEY.to_string(), ledger_status(rec.status))]);
                return (map, StatusSource::JeraLedger, rec.id.clone());
            }
        }
    }
    (HashMap::new(), StatusSource::Static, String::new())
}

/// The sentinel status key meaning "this verdict covers every stage" (the ledger leg).
const UNIFORM_KEY: &str = "*";

/// Seconds between `.runa.ron` + ledger probes for the pipeline pane.
const RUNA_PROBE_S: f64 = 1.0;

/// The **runa** leg of the Pipeline tab: one committed `*.runa.ron` on the reused viewer.
pub struct RunaLeg {
    /// The repo root the files were discovered under.
    root: PathBuf,
    /// Every discovered `*.runa.ron`, in a stable order.
    files: Vec<PathBuf>,
    /// Which one is shown.
    selected: usize,
    /// The GENERATION watch over the SELECTED file plus the jera ledger — the two
    /// inputs `reload()` actually reads. It re-reads when either moves, never on a
    /// press (`crate::generation`).
    watch: crate::generation::Watch,
    /// The parsed pipeline's name (`""` when the file failed to load).
    name: String,
    /// A load/validation error, surfaced honestly instead of a blank pane.
    error: Option<String>,
    /// Stage name → live status (the map handed to `RunaPipelineAdapter::with_status`).
    status: HashMap<String, StageStatus>,
    /// Where that status came from.
    status_source: StatusSource,
    /// The run the status reflects (`""` when static).
    run_id: String,
    /// The stage names, in declaration order (the `state_json` oracle).
    stages: Vec<String>,
    /// The stages the viewer was last built over (same list = same pipeline: a
    /// reload keeps the camera).
    stages_before: Vec<String>,
    /// The reused viewer.
    view: PipelineView,
    /// **Where the run-level light comes from** — the SAME seam the 🧬 Jera tab reads
    /// (`crate::jera::job_source`), injected by the shell. `None` means this leg simply
    /// does not run: a pane with no source says `Static` rather than guessing a
    /// `jobs.redb` path out of the UI's own `$HOME`, which is what it used to do.
    jobs: Option<std::sync::Arc<dyn crate::jera::JeraJobSource>>,
}

impl RunaLeg {
    /// Discover the repo's `*.runa.ron` files and render the first (dwarves' own
    /// `dwarves.runa.ron` sorts first). Never panics — a repo with no pipeline file, or a
    /// file that fails runa's validator, renders an honest empty DAG plus the reason.
    pub fn discover() -> Self {
        let root = runa_repo_root();
        Self::at(&root, 0)
    }

    /// Load the `index`-th `*.runa.ron` found under `root` (clamped).
    pub fn at(root: &Path, index: usize) -> Self {
        let files = discover_runa_files(root);
        let mut me = Self {
            root: root.to_path_buf(),
            files,
            selected: 0,
            name: String::new(),
            error: None,
            status: HashMap::new(),
            status_source: StatusSource::Static,
            run_id: String::new(),
            stages: Vec::new(),
            stages_before: Vec::new(),
            view: PipelineView::new("runa — no pipeline file", Vec::new(), Vec::new()),
            watch: crate::generation::Watch::new(RUNA_PROBE_S),
            jobs: None,
        };
        me.select(index);
        me
    }

    /// Attach the shell's job source and re-read, so the run-level light lands on the
    /// first frame rather than one probe later.
    pub fn with_jobs(mut self, jobs: std::sync::Arc<dyn crate::jera::JeraJobSource>) -> Self {
        self.jobs = Some(jobs);
        self.reload();
        self
    }

    /// **The generation drive.** Called once a frame: re-reads the selected pipeline
    /// and its live status when the FILE or the jera ledger has actually moved. This is
    /// what replaced the `⟳ Reload` button — same read, triggered by the data rather
    /// than by the operator.
    pub fn tick(&mut self, now_s: f64) {
        let Some(file) = self.files.get(self.selected).cloned() else {
            return;
        };
        // The pipeline FILE is still a cheap `stat`. The ledger no longer is: over the
        // wire there is no probe cheaper than the read itself, which is exactly what
        // `JeraJobSource::generation` returns `None` to say. So the source contributes
        // its fingerprint when it HAS one (the named file road) and nothing when it does
        // not — and the `RUNA_PROBE_S` cadence carries the wire leg, as it carries every
        // other remote pane in this shell.
        let jobs_gen = self.jobs.as_ref().and_then(|j| j.generation());
        if self.watch.moved(now_s, || {
            let mut g = crate::generation::paths_generation(std::slice::from_ref(&file));
            if let Some(j) = &jobs_gen {
                g.push_str(j);
            }
            g
        }) {
            self.reload();
        }
    }

    /// Show the `index`-th discovered file (clamped) and re-read its live status.
    pub fn select(&mut self, index: usize) {
        if self.files.is_empty() {
            self.error = Some("no *.runa.ron in this repo".to_string());
            return;
        }
        self.selected = index.min(self.files.len() - 1);
        // A different file is a different QUESTION, not an unchanged source — so the
        // watch reads as cold rather than as "nothing moved".
        self.watch.forget();
        self.reload();
    }

    /// **Show `repo`'s `file`** (`pipelines/release.runa.ron`) — a link from another
    /// tab (🧭 Explorer) lands here. A discovered file is selected; a file of a SIBLING
    /// checkout (`<root>/../<repo>/<file>`) is added and selected; one this UI cannot
    /// read says so by name and the shown pipeline stays.
    pub fn open(&mut self, repo: &str, file: &str) {
        let here = self.root.file_name().and_then(|n| n.to_str()) == Some(repo);
        let want = if here {
            self.root.join(file)
        } else {
            self.root
                .parent()
                .map(|p| p.join(repo).join(file))
                .unwrap_or_default()
        };
        if let Some(i) = self.files.iter().position(|f| *f == want) {
            self.select(i);
            return;
        }
        if want.is_file() {
            self.files.push(want);
            self.select(self.files.len() - 1);
            return;
        }
        self.error = Some(format!(
            "{repo}/{file} is not under this UI's checkouts ({} — set $DWARVES_REPO_ROOT)",
            want.display()
        ));
    }

    /// Re-read the selected file AND its live run status, rebuilding the reused viewer.
    pub fn reload(&mut self) {
        let Some(path) = self.files.get(self.selected).cloned() else {
            return;
        };
        let text = match std::fs::read_to_string(&path) {
            Ok(t) => t,
            Err(e) => {
                self.error = Some(format!("{}: {e}", path.display()));
                return;
            }
        };
        // `from_ron_str` parses AND validates (duplicate stage / unknown `needs` / cycle),
        // so an unsound pipeline can never reach the viewer pretending to be a DAG.
        let pipeline = match dwarves_runa::Pipeline::from_ron_str(&text) {
            Ok(p) => p,
            Err(e) => {
                self.error = Some(format!("{}: {e}", path.display()));
                self.name.clear();
                self.stages.clear();
                self.view = PipelineView::new(
                    format!("runa — {} (invalid)", path.display()),
                    Vec::new(),
                    Vec::new(),
                );
                return;
            }
        };
        self.error = None;
        self.name = pipeline.name.clone();
        self.stages = pipeline.stages.iter().map(|s| s.name.clone()).collect();

        // The LIVE light: per-stage from the run history, else run-level from the jera
        // ledger (expanded over every stage), else all Idle.
        let (mut status, source, run_id) =
            live_stage_status(&self.name, &warehouse_root(), self.jobs.as_deref());
        if let Some(uniform) = status.remove(UNIFORM_KEY) {
            for s in &self.stages {
                status.insert(s.clone(), uniform);
            }
        }
        self.status = status;
        self.status_source = source;
        self.run_id = run_id;

        // The REUSED viewer, via the REUSED runa adapter. No twin. A reload of the SAME
        // pipeline (new run status) keeps the viewer's camera — only a different
        // pipeline is framed afresh.
        let keep = !self.stages.is_empty() && self.stages_before == self.stages;
        let (nav, fitted) = (*self.view.nav(), self.view.is_fitted());
        self.view = PipelineView::from_adapter(
            &RunaPipelineAdapter::new(&pipeline).with_status(self.status.clone()),
        );
        if keep && fitted {
            self.view.set_camera(nav, fitted);
        }
        self.stages_before = self.stages.clone();
    }

    /// The file currently rendered (`""` when none was found).
    pub fn file(&self) -> String {
        self.files
            .get(self.selected)
            .and_then(|p| p.file_name().and_then(|n| n.to_str()))
            .unwrap_or_default()
            .to_string()
    }
}

// `jera_ledger_root()` used to live here, and its doc comment said it was "the same
// `~/.dwarves/jobs` location the Jera tab polls". That stopped being true at `9e82007`
// and nothing caught it, which is the whole hazard: a comment describing a road the code
// no longer takes is how the next reader loses an afternoon. The road is
// `crate::jera::job_source` now, and `$DWARVES_JOBS_DIR` is the one env var that names a
// file (`$DWARVES_JERA_LEDGER` selected a path only this pane ever read).

// ── the two-source pane ──────────────────────────────────────────────────────────

/// Which pipeline the tab is showing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PipelineSource {
    /// The authored `*.runa.ron` CI/CD pipeline (the default).
    Runa,
    /// The selected pipeline's real runs, read from the forge.
    Runs,
}

impl PipelineSource {
    /// The stable lower-case tag used in `state_json` / the JSON seam.
    pub fn key(self) -> &'static str {
        match self {
            PipelineSource::Runa => "runa",
            PipelineSource::Runs => "runs",
        }
    }
}

/// The runs leg's viewer: its runs are drawn as tables (no stage DAG of its own).
pub fn forge_view() -> PipelineView {
    PipelineView::new("runs", Vec::new(), Vec::new())
}

/// The **Pipeline** tab: the runa authoring DAG and the brokkr build DAG on ONE reused
/// viewer, switchable in the header (and over the JSON seam).
pub struct PipelinePane {
    source: PipelineSource,
    runa: RunaLeg,
    forge: PipelineView,
    runs: crate::pipeline_runs::RunsLeg,
}

impl PipelinePane {
    /// The shipped pane: the repo's own `*.runa.ron` first, the live brokkr run behind it.
    pub fn new() -> Self {
        Self {
            source: PipelineSource::Runa,
            runa: RunaLeg::discover(),
            forge: forge_view(),
            runs: crate::pipeline_runs::RunsLeg::new("", ""),
        }
    }

    /// The pane rooted at a specific repo (tests / a host that already resolved a root).
    pub fn at(root: &Path) -> Self {
        Self {
            source: PipelineSource::Runa,
            runa: RunaLeg::at(root, 0),
            forge: forge_view(),
            runs: crate::pipeline_runs::RunsLeg::new("", ""),
        }
    }

    /// The shipped pane WITH the shell's job source — what `dwarves-gui` mounts. Split
    /// from [`new`](Self::new) so a test can still build the pane with no wire at all.
    pub fn with_jobs(jobs: std::sync::Arc<dyn crate::jera::JeraJobSource>) -> Self {
        Self {
            source: PipelineSource::Runa,
            runa: RunaLeg::discover().with_jobs(jobs),
            forge: forge_view(),
            runs: crate::pipeline_runs::RunsLeg::new("", ""),
        }
    }

    /// Read the selected pipeline's runs from the forge at `endpoint` (builder style).
    pub fn with_forge(mut self, endpoint: impl Into<String>, token: impl Into<String>) -> Self {
        self.runs = crate::pipeline_runs::RunsLeg::new(endpoint, token);
        self.runs.set_pipeline(&self.runa.name);
        self
    }

    /// Which source is showing.
    pub fn source(&self) -> PipelineSource {
        self.source
    }

    /// Switch source (the header toggle + the `{"pipeline_source":…}` seam).
    pub fn set_source(&mut self, source: PipelineSource) {
        self.source = source;
    }

    /// The active leg's reused viewer (read-only — camera, where the DAG is on screen).
    pub fn viewer(&self) -> &PipelineView {
        self.view()
    }

    /// The active leg's reused viewer.
    fn view(&self) -> &PipelineView {
        match self.source {
            PipelineSource::Runa => &self.runa.view,
            PipelineSource::Runs => &self.forge,
        }
    }

    fn view_mut(&mut self) -> &mut PipelineView {
        match self.source {
            PipelineSource::Runa => &mut self.runa.view,
            PipelineSource::Runs => &mut self.forge,
        }
    }
}

impl Default for PipelinePane {
    fn default() -> Self {
        Self::new()
    }
}

impl Facet for PipelinePane {
    fn title(&self) -> &str {
        "Pipeline"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        // Both halves, in order — the shape this pane keeps when mounted ALONE (the unit
        // tests below box it bare). In the shell it is a `SplitPanel`: the stage DAG and
        // the source/stage detail become two scene panes with a draggable divider.
        self.source_ui(ui);
        ui.separator();
        self.dag_ui(ui);
    }

    fn state_json(&self) -> serde_json::Value {
        self.folded_state_json()
    }

    fn update_json(&mut self, msg_json: &str) {
        self.apply_msg(msg_json);
    }
}

impl PipelinePane {
    /// **The SOURCE + stage detail — the inspector half.** Which source is lit (runa
    /// `*.runa.ron` vs a live brokkr forge run), which file, how many stages, where the
    /// per-stage light came from, and the reload.
    ///
    /// This was a `ui.horizontal` header ABOVE the DAG in one flow: a runa file with a
    /// long name, a parse error, or a wide stage summary WRAPS that row, and every
    /// wrapped line came straight out of the graph's height with no divider to take it
    /// back. It is its own window now. Returns the pane's own observable state.
    fn source_ui(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
        // GENERATION, not a press. `source_ui` is the one place drawn on every frame in
        // BOTH mountings (bare `Facet::ui` and the split shell's `ui_inspector`), so the
        // drive lives here rather than being duplicated per mounting (LAW 5).
        self.runa.tick(ui.input(|i| i.time));
        self.runs.set_pipeline(&self.runa.name);

        // The honest one-line header: which source, which file, and where the per-stage
        // light came from (PLAIN words + PLAIN numbers — a nordisk law).
        let mut want: Option<PipelineSource> = None;
        // Nothing in the UI sets this any more; kept so the switch below reads
        // unchanged. The generation drive owns re-reading.
        let reload = false;
        ui.horizontal(|ui| {
            if ui
                .selectable_label(self.source == PipelineSource::Runa, "runa (*.runa.ron)")
                .on_hover_text("the authored CI/CD pipeline, as data")
                .clicked()
            {
                want = Some(PipelineSource::Runa);
            }
            if ui
                .selectable_label(self.source == PipelineSource::Runs, "runs (forge)")
                .on_hover_text("this pipeline's real runs, read from the forge: steps, logs, units")
                .clicked()
            {
                want = Some(PipelineSource::Runs);
            }
            ui.separator();
            match self.source {
                PipelineSource::Runa => {
                    if let Some(err) = &self.runa.error {
                        ui.label(format!("⚠ {err}"));
                    } else {
                        ui.label(format!(
                            "{} · {} stages · status: {}",
                            self.runa.file(),
                            self.runa.stages.len(),
                            self.runa.status_source.key(),
                        ));
                    }
                    // NO ⟳ Reload button (owner 2026-08-25: SCAN is not a user
                    // action). The file and its live run status are re-read when the
                    // `.runa.ron` or the jera ledger MOVES — see the generation drive
                    // in `ui`.
                }
                PipelineSource::Runs => {
                    ui.label(self.runs.line());
                }
            }
        });
        if let Some(s) = want {
            self.source = s;
        }
        if reload {
            self.runa.reload();
        }
        serde_json::json!({
            "source": self.source.key(),
            "pipeline_file": self.runa.file(),
            "stage_count": self.runa.stages.len(),
            "status_source": self.runa.status_source.key(),
        })
    }

    /// **The stage DAG — the master half.** The reused `facett-pipeline` viewer, alone.
    fn dag_ui(&mut self, ui: &mut egui::Ui) {
        match self.source {
            PipelineSource::Runa => self.view_mut().ui(ui),
            PipelineSource::Runs => self.runs.ui(ui),
        }
    }

    fn folded_state_json(&self) -> serde_json::Value {
        // Fold the ACTIVE reused viewer's own state (nodes · edges · statuses) and ADD
        // the pane's provenance oracle: which source, which file, which pipeline, where
        // the per-stage light came from, and which run it reflects. Everything the pane
        // SHOWS is assertable from here (L4).
        let mut v = self.view().state_json();
        let (idle, running, done, failed) = self.view().counts();
        if let Some(obj) = v.as_object_mut() {
            obj.insert("kind".into(), serde_json::json!("pipeline"));
            obj.insert("source".into(), serde_json::json!(self.source.key()));
            // The reused viewer already folds `statuses` — this is the same tuple read
            // through the view's own `counts()`, kept so a driver can assert the totals
            // without walking the node list.
            obj.insert(
                "stage_status_counts".into(),
                serde_json::json!({
                    "idle": idle, "running": running, "done": done, "failed": failed,
                }),
            );
            match self.source {
                PipelineSource::Runa => {
                    obj.insert("pipeline_file".into(), serde_json::json!(self.runa.file()));
                    obj.insert("pipeline_name".into(), serde_json::json!(self.runa.name));
                    obj.insert("stages".into(), serde_json::json!(self.runa.stages));
                    obj.insert(
                        "stage_count".into(),
                        serde_json::json!(self.runa.stages.len()),
                    );
                    obj.insert(
                        "status_source".into(),
                        serde_json::json!(self.runa.status_source.key()),
                    );
                    obj.insert("run_id".into(), serde_json::json!(self.runa.run_id));
                    obj.insert(
                        "available_pipelines".into(),
                        serde_json::json!(self
                            .runa
                            .files
                            .iter()
                            .filter_map(|p| p.file_name().and_then(|n| n.to_str()))
                            .collect::<Vec<_>>()),
                    );
                    obj.insert(
                        "error".into(),
                        serde_json::json!(self.runa.error.clone().unwrap_or_default()),
                    );
                }
                PipelineSource::Runs => {
                    let runs = self.runs.state_json();
                    obj.insert("pipeline_name".into(), serde_json::json!(self.runa.name));
                    obj.insert("status_source".into(), serde_json::json!("forge"));
                    obj.insert("run_id".into(), runs["run_id"].clone());
                    obj.insert("note".into(), runs["line"].clone());
                    obj.insert("runs".into(), runs);
                }
            }
        }
        v
    }

    fn apply_msg(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
            return;
        };
        if let Some(s) = v.get("pipeline_source").and_then(|s| s.as_str()) {
            match s {
                "runa" => self.source = PipelineSource::Runa,
                "runs" | "forge" => {
                    self.source = PipelineSource::Runs;
                    self.runs.set_pipeline(&self.runa.name);
                    self.runs.ask();
                }
                _ => {}
            }
            return;
        }
        // the runs leg: {"runs_wait":true} · {"runs_select":"<run>"} · {"runs_step":n} · {"runs_full_log":n}
        if v.get("runs_wait").and_then(|r| r.as_bool()) == Some(true) {
            self.runs.set_pipeline(&self.runa.name);
            self.runs.wait();
            return;
        }
        if let Some(id) = v.get("runs_select").and_then(|r| r.as_str()) {
            self.runs.select_run(id);
            self.runs.wait();
            return;
        }
        if let Some(n) = v.get("runs_step").and_then(|r| r.as_u64()) {
            self.runs.select_step(n as u32, false);
            self.runs.wait();
            return;
        }
        if let Some(n) = v.get("runs_full_log").and_then(|r| r.as_u64()) {
            self.runs.select_step(n as u32, true);
            self.runs.wait();
            return;
        }
        if let Some(i) = v.get("select_pipeline").and_then(|i| i.as_u64()) {
            self.runa.select(i as usize);
            return;
        }
        // `{"open_pipeline":{"repo":…,"file":…}}` — a link (🧭 Explorer) carries the file
        if let Some(o) = v.get("open_pipeline") {
            self.source = PipelineSource::Runa;
            self.runa.open(
                o["repo"].as_str().unwrap_or(""),
                o["file"].as_str().unwrap_or(""),
            );
            return;
        }
        if v.get("reload").and_then(|r| r.as_bool()) == Some(true) {
            self.runa.reload();
        }
    }
}

/// **The Pipeline body DECOMPOSES**: the stage DAG is the master, the source + stage
/// detail the inspector — two scene panes with a draggable divider, each with its own
/// chrome bar.
impl crate::tabbed::SplitPanel for PipelinePane {
    fn ui_main(&mut self, ui: &mut egui::Ui) {
        self.dag_ui(ui);
    }

    fn ui_inspector(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
        self.source_ui(ui)
    }
}

/// The tab's panel — the shipped construction path the shell mounts.
pub fn pipeline_view() -> PipelinePane {
    PipelinePane::new()
}

/// The shipped mount: the pane over the shell's ONE job source.
pub fn pipeline_view_with_jobs(
    jobs: std::sync::Arc<dyn crate::jera::JeraJobSource>,
) -> PipelinePane {
    PipelinePane::with_jobs(jobs)
}

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

    /// **A link opens a pipeline by repo + file**: one of the root's own files, a
    /// sibling checkout's file (added), and a file that is not there named by its path.
    #[test]
    fn a_link_opens_its_pipeline_file() {
        let dir = tempfile::tempdir().unwrap();
        let own = dir.path().join("dwarves");
        std::fs::create_dir_all(own.join("pipelines")).unwrap();
        std::fs::write(
            own.join("dwarves.runa.ron"),
            r#"(name: "dwarves-forge", stages: [(name: "a", steps: [CargoTest])])"#,
        )
        .unwrap();
        std::fs::write(
            own.join("pipelines/release.runa.ron"),
            r#"(name: "release", stages: [(name: "r", steps: [CargoTest])])"#,
        )
        .unwrap();
        std::fs::create_dir_all(dir.path().join("tunnr")).unwrap();
        std::fs::write(
            dir.path().join("tunnr/tunnr.runa.ron"),
            r#"(name: "tunnr-ci", stages: [(name: "t", steps: [CargoTest])])"#,
        )
        .unwrap();
        let mut leg = RunaLeg::at(&own, 0);
        assert_eq!(leg.name, "dwarves-forge");
        leg.open("dwarves", "pipelines/release.runa.ron");
        assert_eq!(
            (leg.file().as_str(), leg.name.as_str()),
            ("release.runa.ron", "release")
        );
        leg.open("tunnr", "tunnr.runa.ron");
        assert_eq!(
            (leg.file().as_str(), leg.name.as_str(), leg.error.clone()),
            ("tunnr.runa.ron", "tunnr-ci", None)
        );
        leg.open("knut", "knut.runa.ron");
        assert!(
            leg.error
                .as_deref()
                .unwrap()
                .contains("knut/knut.runa.ron is not under this UI's checkouts"),
            "{:?}",
            leg.error
        );
        assert_eq!(leg.name, "tunnr-ci", "the shown pipeline stays");
    }

    /// **The Pipeline tab's DAG opens centred** — rendered: dwarves' OWN committed
    /// `dwarves.runa.ron` beside a 320 px rail; the DAG's box is centred (±2 px) in the
    /// part of its canvas the viewer sees and wholly inside it; a reload of the same
    /// pipeline (new run status) keeps the viewer's camera.
    #[test]
    fn the_pipeline_dag_opens_centred_and_a_reload_keeps_the_camera() {
        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().to_path_buf();
        let pane = std::rc::Rc::new(std::cell::RefCell::new(PipelinePane::at(&root)));
        let p2 = pane.clone();
        let mut h = egui_kittest::Harness::builder()
            .with_size(egui::vec2(1400.0, 900.0))
            .build_ui(move |ui| {
                egui::Panel::left("rail").exact_size(320.0).resizable(false).show_inside(ui, |ui| {
                    ui.label("rail");
                });
                egui::CentralPanel::default().show_inside(ui, |ui| Facet::ui(&mut *p2.borrow_mut(), ui));
            });
        for _ in 0..4 {
            h.step();
        }
        let (seen, b) = {
            let p = pane.borrow();
            (p.viewer().seen_rect().expect("painted"), p.viewer().screen_box().expect("stages"))
        };
        assert!((b.center() - seen.center()).length() <= 2.0, "centred: box {b:?} in {seen:?}");
        assert!(seen.expand(0.5).contains_rect(b), "fully visible: box {b:?} in {seen:?}");
        // the viewer pans; a reload of the same pipeline keeps where they are
        pane.borrow_mut().runa.view.zoom_to_cursor(1.5, seen.center());
        let nav = *pane.borrow().viewer().nav();
        pane.borrow_mut().runa.reload();
        h.step();
        assert_eq!(*pane.borrow().viewer().nav(), nav, "a reload keeps the camera");
    }

    fn cell(name: &str, ok: bool, detail: &str) {
        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status("dwarves-gui", name, ok, detail);
        let _ = (name, ok, detail);
    }

    /// RED-when-broken: dwarves' OWN committed `dwarves.runa.ron` loads through runa's
    /// parser+validator and renders on the REUSED `facett-pipeline` viewer via
    /// `RunaPipelineAdapter` — one node per stage, one edge per `needs`, and the typed
    /// steps as the node sublabel. This is the dogfood proof: the forge's UI shows the
    /// forge's own authored pipeline, not a hand-typed fixture.
    #[test]
    fn dwarves_own_runa_pipeline_renders_on_the_reused_viewer() {
        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .to_path_buf();
        let pane = PipelinePane::at(&root);
        let s = Facet::state_json(&pane);

        assert_eq!(
            s["source"], "runa",
            "the Pipeline tab opens on the runa view"
        );
        assert_eq!(
            s["error"], "",
            "dwarves.runa.ron parses AND passes runa's validator"
        );
        assert_eq!(
            s["pipeline_file"], "dwarves.runa.ron",
            "the repo's own pipeline sorts first"
        );
        assert_eq!(s["pipeline_name"], "dwarves-forge");
        // lint → build → test → ui → doctor.
        let stages: Vec<&str> = s["stages"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap())
            .collect();
        assert_eq!(
            stages,
            vec!["lint", "build", "test", "ui", "doctor"],
            "one node per authored stage"
        );
        assert_eq!(
            s["node_count"], 5,
            "the reused viewer holds a node per stage"
        );
        // needs edges: build→lint, test→build, ui→build, doctor→test, doctor→ui = 5.
        assert_eq!(s["edge_count"], 5, "one edge per `needs` entry");
        // The typed runa steps become the node sublabel (the `kind` the adapter builds).
        let lint = s["nodes"]
            .as_array()
            .unwrap()
            .iter()
            .find(|n| n["name"] == "lint")
            .unwrap();
        // `Lint(tool: Fmt)` + `Lint(tool: Clippy)` since cb05921 (the fmt gate was an
        // `Xtask(cmd: "fmt-check")` verb the xtask never had).
        assert_eq!(
            lint["detail"], "Lint, Lint",
            "the stage's typed steps are the sublabel"
        );

        let ok =
            s["source"] == "runa" && s["pipeline_name"] == "dwarves-forge" && s["node_count"] == 5;
        cell(
            "pipeline_runa_view_renders_the_committed_ron",
            ok,
            "the Pipeline tab renders dwarves' OWN committed dwarves.runa.ron (5 stages, 5 `needs` edges, typed steps as sublabels) through runa's validator + the REUSED facett-pipeline viewer via RunaPipelineAdapter — no viewer twin, no fixture",
        );
    }

    /// RED-when-broken: with no run of this pipeline recorded, every stage is HONESTLY
    /// Idle and the pane SAYS so (`status_source: "static"`). A pane that greened stages
    /// it never ran would red here.
    #[test]
    fn an_unrun_pipeline_is_honestly_idle() {
        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .to_path_buf();
        // Point the status readers at empty roots so the test never depends on this box's
        // warehouse/ledger contents.
        let _guard = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let empty =
            std::env::temp_dir().join(format!("dwarves-gui-no-warehouse-{}", std::process::id()));
        let prev_wh = std::env::var("DWARVES_WAREHOUSE").ok();
        unsafe {
            std::env::set_var("DWARVES_WAREHOUSE", &empty);
        }
        // No `DWARVES_JERA_LEDGER` any more: the ledger leg is a SEAM, not a path, and
        // `PipelinePane::at` injects none — so this pane genuinely has one source of
        // light (the warehouse history) and it is empty.
        let pane = PipelinePane::at(&root);
        let s = Facet::state_json(&pane);
        let idle_ok = s["status_source"] == "static"
            && s["run_id"] == ""
            && s["stage_status_counts"]["idle"] == 5
            && s["stage_status_counts"]["done"] == 0
            && s["stage_status_counts"]["running"] == 0
            && s["stage_status_counts"]["failed"] == 0;
        assert_eq!(
            s["status_source"], "static",
            "no run recorded ⇒ the honest static light"
        );
        assert_eq!(
            s["stage_status_counts"]["idle"], 5,
            "every stage is Idle, none invented green"
        );
        unsafe {
            match prev_wh {
                Some(v) => std::env::set_var("DWARVES_WAREHOUSE", v),
                None => std::env::remove_var("DWARVES_WAREHOUSE"),
            }
        }
        cell(
            "pipeline_runa_unrun_is_honestly_idle",
            idle_ok,
            "a *.runa.ron with no recorded run lights every stage Idle and reports status_source:\"static\" — the pane never invents a green stage",
        );
    }

    /// ★ **THE RUN-LEVEL LIGHT COMES OFF THE SEAM, NOT OFF A PATH.**
    ///
    /// `9e82007` moved the 🧬 Jera tab from a `jobs.redb` the UI derived out of its own
    /// `$HOME` onto `Viz.Jobs`, and this pane kept the old road — so the shell shipped
    /// two panes reading "the ledger" by two different roads, and on a real deployment
    /// the guessed one is the wrong file (the served workspace's ledger lives under the
    /// SERVER's nornir warehouse, owned by user `dwarves` and `-rw-r--r--`, and
    /// `JobStore::open` needs WRITE access even to read).
    ///
    /// The proof is a source that could not possibly be a file: an in-memory fake. It
    /// lights the DAG, so the pane is reading the SEAM. The warehouse is pointed at an
    /// empty dir so leg (1) cannot be what lit it, and the run id the pane reports has to
    /// be the fake's job id — a value no local read could synthesise.
    #[test]
    fn the_run_level_light_comes_from_the_injected_source_not_a_guessed_path() {
        use crate::jera::{JeraJobSource, JobsRead};
        use facett_jobview::{JobEntry, JobStatus};

        struct FakeJobs;
        impl JeraJobSource for FakeJobs {
            fn read(&self, _limit: usize, _kind: &str) -> JobsRead {
                // The crate's OWN constructor, not a struct literal — `JobEntry` grows
                // fields and its `new` is what absorbs that.
                let mut e = JobEntry::new("wire-job-42", "ci_build", JobStatus::Failed);
                // The ledger's `target` is the PIPELINE name — what the pane matches on.
                e.target = "dwarves-forge".to_string();
                e.started_micros = 1;
                e.elapsed_ms = Some(1);
                JobsRead::Rows(vec![e])
            }
            fn describe(&self) -> String {
                "in-memory fake".to_string()
            }
        }

        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .to_path_buf();
        let _guard = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let empty =
            std::env::temp_dir().join(format!("dwarves-gui-seam-wh-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&empty);
        let prev_wh = std::env::var("DWARVES_WAREHOUSE").ok();
        unsafe { std::env::set_var("DWARVES_WAREHOUSE", &empty) };

        let pane = PipelinePane {
            source: PipelineSource::Runa,
            runa: RunaLeg::at(&root, 0).with_jobs(std::sync::Arc::new(FakeJobs)),
            forge: forge_view(),
            runs: crate::pipeline_runs::RunsLeg::new("", ""),
        };
        let s = Facet::state_json(&pane);

        unsafe {
            match prev_wh {
                Some(v) => std::env::set_var("DWARVES_WAREHOUSE", v),
                None => std::env::remove_var("DWARVES_WAREHOUSE"),
            }
        }

        // APPLIED OUTPUT: the verdict travelled, it is attributed to the LEDGER leg, and
        // it carries the id only the injected source knows.
        assert_eq!(
            s["status_source"], "jera-ledger",
            "the run-level light must be attributed to the ledger leg, got {s}"
        );
        assert_eq!(
            s["run_id"], "wire-job-42",
            "the pane must report the id the SOURCE gave it — a value no path read could \
             have produced"
        );
        assert_eq!(
            s["stage_status_counts"]["failed"], 5,
            "a run-level FAILED verdict covers every stage of the run, got {s}"
        );

        let ok = s["status_source"] == "jera-ledger" && s["run_id"] == "wire-job-42";
        cell(
            "pipeline_ledger_leg_reads_the_shared_seam",
            ok,
            "the Pipeline tab's run-level light comes through crate::jera::JeraJobSource — the SAME seam the Jera tab reads over Viz.Jobs — instead of dwarves_embed::read_ledger_jobs on a $HOME-derived jobs.redb path; proven with an in-memory source that cannot be a file",
        );
    }

    /// RED-when-broken (the LIVE light): historised run rows for the pipeline light the
    /// DAG per stage — a failed stage reads Failed, a running one Running, a green one
    /// Done, and an unrun one stays Idle. Driven through the SAME warehouse reader the
    /// pane uses, with a seeded history, so the assertion is on the RESULT (the per-stage
    /// statuses), never a literal `true`.
    #[test]
    fn historised_run_rows_light_the_runa_dag_per_stage() {
        use dwarves::brokkr::history::{BuildRow, PipelineRunRow};

        let _guard = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let wh = std::env::temp_dir().join(format!("dwarves-gui-runa-wh-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&wh);
        let run_id = "runa-run-7";
        let row = |stage: &str, step: &str, status| BuildRow {
            run_id: run_id.to_string(),
            step: step.to_string(),
            stage: stage.to_string(),
            backend: "process".into(),
            matrix: String::new(),
            status,
            summary: String::new(),
            artifact_id: None,
            job_ref: None,
            duration_ms: 0,
            finished_at: String::new(),
        };
        let run = PipelineRunRow {
            run_id: run_id.to_string(),
            pipeline: "dwarves-forge".to_string(),
            project: "dwarves".to_string(),
            trigger: "manual".to_string(),
            sha: None,
            status: RunStatus::Failed,
            steps: 4,
            passed: 2,
            duration_ms: 0,
            job_ref: None,
            finished_at: String::new(),
        };
        let steps = vec![
            row("lint", "Xtask", RunStatus::Passed),
            row("lint", "Lint", RunStatus::Passed),
            row("build", "Build", RunStatus::Running),
            row("test", "Nextest", RunStatus::Failed),
            // `ui` + `doctor` never ran — they must stay Idle.
        ];
        dwarves::store::append_history(&wh, &run, &steps).expect("seed the run history");

        let prev_wh = std::env::var("DWARVES_WAREHOUSE").ok();
        let prev_led = std::env::var("DWARVES_JERA_LEDGER").ok();
        unsafe {
            std::env::set_var("DWARVES_WAREHOUSE", &wh);
            std::env::set_var("DWARVES_JERA_LEDGER", &wh);
        }
        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .to_path_buf();
        let pane = PipelinePane::at(&root);
        let s = Facet::state_json(&pane);
        unsafe {
            match prev_wh {
                Some(v) => std::env::set_var("DWARVES_WAREHOUSE", v),
                None => std::env::remove_var("DWARVES_WAREHOUSE"),
            }
            match prev_led {
                Some(v) => std::env::set_var("DWARVES_JERA_LEDGER", v),
                None => std::env::remove_var("DWARVES_JERA_LEDGER"),
            }
        }

        let status_of = |name: &str| -> String {
            s["nodes"]
                .as_array()
                .unwrap()
                .iter()
                .find(|n| n["name"] == name)
                .map(|n| n["status"].as_str().unwrap_or_default().to_string())
                .unwrap_or_default()
        };
        let lit_ok = s["status_source"] == "run-history"
            && s["run_id"] == run_id
            && status_of("lint") == "done"
            && status_of("build") == "running"
            && status_of("test") == "failed"
            && status_of("ui") == "idle"
            && status_of("doctor") == "idle";
        assert_eq!(
            s["status_source"], "run-history",
            "the live light comes from the historised run"
        );
        assert_eq!(s["run_id"], run_id, "and names the run it reflects");
        assert_eq!(
            status_of("lint"),
            "done",
            "two green steps ⇒ the stage is Done"
        );
        assert_eq!(
            status_of("build"),
            "running",
            "a running step keeps the stage Running"
        );
        assert_eq!(status_of("test"), "failed", "a failed step fails the stage");
        assert_eq!(
            status_of("ui"),
            "idle",
            "a stage with no rows never started"
        );
        assert_eq!(
            s["stage_status_counts"],
            serde_json::json!({"idle": 2, "running": 1, "done": 1, "failed": 1})
        );

        let _ = std::fs::remove_dir_all(&wh);
        cell(
            "pipeline_runa_live_status_from_the_run_ledger",
            lit_ok,
            "a historised jera-dispatched run lights the runa DAG PER STAGE on the reused viewer (done/running/failed/idle folded from the step rows), and the pane names the run it reflects — asserted on the resulting per-node statuses, not a literal true",
        );
    }

    /// The stage fold is the tested core of the live light: pure, total, and never
    /// optimistic (a single failure fails the stage, a mixed/skipped set is not Done).
    #[test]
    fn stage_fold_is_never_optimistic() {
        assert_eq!(fold_stage_status(&[]), StageStatus::Idle);
        assert_eq!(
            fold_stage_status(&[RunStatus::Passed, RunStatus::Passed]),
            StageStatus::Done
        );
        assert_eq!(
            fold_stage_status(&[RunStatus::Passed, RunStatus::Failed]),
            StageStatus::Failed
        );
        assert_eq!(
            fold_stage_status(&[RunStatus::Running, RunStatus::Failed]),
            StageStatus::Failed,
            "a failure outranks an in-flight step"
        );
        assert_eq!(
            fold_stage_status(&[RunStatus::Passed, RunStatus::Running]),
            StageStatus::Running
        );
        assert_eq!(
            fold_stage_status(&[RunStatus::Passed, RunStatus::Skipped]),
            StageStatus::Idle,
            "a skipped step means the stage did NOT complete"
        );
    }

    /// The RUNS leg draws no stage of its own and invents no run: a pane with no forge
    /// says so (`no_forge`), with the selected pipeline named; switching back restores
    /// the runa DAG.
    #[test]
    fn the_runs_leg_without_a_forge_says_so_and_draws_no_run() {
        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .to_path_buf();
        let mut pane = PipelinePane::at(&root);
        Facet::update_json(&mut pane, r#"{"pipeline_source":"runs"}"#);
        let s = Facet::state_json(&pane);
        let runs_ok = s["source"] == "runs"
            && s["node_count"] == 0
            && s["status_source"] == "forge"
            && s["runs"]["state"] == "no_forge"
            && s["run_id"].is_null()
            && s["pipeline_name"] == "dwarves-forge"
            && s["stage_status_counts"]["done"] == 0;
        assert!(runs_ok, "no forge: said, no run drawn: {s}");
        assert!(s["note"].as_str().unwrap_or("").contains("no forge"), "{s}");
        // the old name of the leg still reaches it
        Facet::update_json(&mut pane, r#"{"pipeline_source":"forge"}"#);
        assert_eq!(Facet::state_json(&pane)["source"], "runs");

        Facet::update_json(&mut pane, r#"{"pipeline_source":"runa"}"#);
        let back = Facet::state_json(&pane);
        let back_ok = back["source"] == "runa" && back["pipeline_name"] == "dwarves-forge";
        assert!(back_ok, "{back}");

        cell(
            "pipeline_source_toggle_keeps_both_dags",
            runs_ok && back_ok,
            "the Pipeline tab's {\"pipeline_source\":…} seam switches runa ⇄ runs: runa renders its authored DAG; the runs leg reads the forge and, with none, says no_forge and draws no run",
        );
    }

    /// The repo's other committed pipelines are reachable from the pane (the selector),
    /// so `pipelines/release.runa.ron` + `holger.runa.ron` are not invisible.
    #[test]
    fn every_committed_runa_pipeline_is_selectable() {
        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .to_path_buf();
        let mut pane = PipelinePane::at(&root);
        let s = Facet::state_json(&pane);
        let avail: Vec<String> = s["available_pipelines"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap().to_string())
            .collect();
        assert!(avail.contains(&"dwarves.runa.ron".to_string()));
        assert!(avail.contains(&"holger.runa.ron".to_string()));
        assert!(
            avail.contains(&"release.runa.ron".to_string()),
            "pipelines/ is discovered too: {avail:?}"
        );

        // Select the holger products pipeline over the seam and assert THAT graph renders.
        let idx = avail.iter().position(|f| f == "holger.runa.ron").unwrap();
        Facet::update_json(
            &mut pane,
            &serde_json::json!({ "select_pipeline": idx }).to_string(),
        );
        let h = Facet::state_json(&pane);
        assert_eq!(h["pipeline_name"], "holger-products");
        assert_eq!(h["pipeline_file"], "holger.runa.ron");
        assert_eq!(h["stage_count"], 4, "build · test · package · release");
    }
}