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

nordisk/dwarves

crates/dwarves-embed/src/cicdwizard.rs

//! The **CI/CD quick-setup wizard** pane — the PRIORITY front door of the human-mode
//! forge. A human picks what to BUILD (artifact kind × target), a TEST step, a DEPLOY
//! target and a TRIGGER (+ an SSH source repo); the pane GENERATES a typed
//! `dwarves_runa::Pipeline` via [`dwarves::pipeline_wizard`] and shows the LIVE stage DAG on the
//! REUSED [`facett_pipeline::PipelineView`] — lit through
//! [`facett_pipeline::RunaPipelineAdapter`] (Law #1: "show another engine's pipeline =
//! a new ADAPTER, not a new viewer"). A **Run** dispatches the pipeline as real jera
//! jobs over a FAKE scheduler (no process spawn, no deploy) so the human sees the DAG
//! go green; **Save** writes the `*.runa.ron`.
//!
//! ONE code path, two front doors: this pane and the `dwarves pipeline` CLI both call
//! the SAME `pipeline_wizard::generate_pipeline` + the SAME `RunaStepRunner`/jera
//! runner — they never fork the schema or the run.
//!
//! Robot-testable: the whole wizard is driven headless through [`Facet::update_json`]
//! (`{"set_build":…}`, `{"set_deploy":…}`, `{"generate":true}`, `{"run":true}`) and
//! asserted through [`Facet::state_json`] (the generated pipeline, the DAG node/edge
//! counts, the run's dispatched jobs).

use std::collections::HashMap;

use facett_core::{Facet, Panel};
use facett_pipeline::{PipelineView, RunaPipelineAdapter, StageStatus};

use dwarves::pipeline_wizard::{
    self, ArtifactKind, DeployChoice, GitSource, TestKind, TriggerChoice, WizardConfig,
};

/// The wizard pane state. The `cfg` is the human's picks; `pipeline` is regenerated
/// from it on every change; `status` lights the DAG (all `Idle` until a Run).
pub struct CicdWizardPanel {
    cfg: WizardConfig,
    pipeline: dwarves_runa::Pipeline,
    status: HashMap<String, StageStatus>,
    /// The last run's outcome (dispatched jobs + green), for the readout.
    last_run: Option<LastRun>,
    /// Manageability: the pipelines generated this session (names) + files saved.
    generated: Vec<String>,
    saved_files: Vec<String>,
    /// The **credential-injection map** the human declares for the runner: each
    /// `(vault_secret_name, env_key)` is pulled from the UNLOCKED vault into the runner
    /// env on a Run (via `dwarves::vault::inject_secret_env`). These are NAMES + KEYS only
    /// — never a secret value. Empty ⇒ the vault-free wizard.
    cred_injections: Vec<(String, String)>,
    /// The env KEYS the LAST run actually injected from the vault (the RED-when-broken
    /// oracle that the cred→pipeline hop reached the runner) — KEYS only, never values.
    injected_keys: Vec<String>,
    /// Whether the last Run was REFUSED because a declared credential could not be pulled
    /// (a LOCKED vault / missing secret — the fail-closed path).
    run_refused: bool,
    /// Where `Save` writes `<name>.runa.ron` (`None` = the current dir). A host/robot sets
    /// it so the artifact lands in a chosen dir rather than cwd.
    save_dir: Option<std::path::PathBuf>,
    /// egui text buffers.
    name_buf: String,
    git_buf: String,
    /// A transient status/error line shown under the buttons.
    note: String,
}

#[derive(Clone)]
struct LastRun {
    dispatched: usize,
    green: bool,
    stage_order: Vec<String>,
}

/// The height the action row (`▶ Run (dry)` · `💾 Save .runa.ron` · the validity word)
/// plus its separator is reserved at the BOTTOM of the pane, so those two buttons are on
/// the rendered surface whatever the window height is. Not a guess: the row measured
/// 18 px of button plus egui's item spacing and the separator above it, and the reserve
/// is deliberately generous — under-reserving costs the operator the primary action,
/// over-reserving costs a few pixels of scroll body.
const ACTION_ROW_HEIGHT: f32 = 44.0;

impl Default for CicdWizardPanel {
    fn default() -> Self {
        Self::new("my-ci")
    }
}

impl CicdWizardPanel {
    /// A fresh wizard opened on the minimal-viable default (build a Linux exe →
    /// nextest → no deploy → manual, on the fast container runner).
    pub fn new(name: impl Into<String>) -> Self {
        let cfg = pipeline_wizard::default_config(name);
        let name_buf = cfg.name.clone();
        let mut p = Self {
            pipeline: pipeline_wizard::generate_pipeline(&cfg),
            status: HashMap::new(),
            last_run: None,
            generated: vec![cfg.name.clone()],
            saved_files: Vec::new(),
            cred_injections: Vec::new(),
            injected_keys: Vec::new(),
            run_refused: false,
            save_dir: None,
            name_buf,
            git_buf: String::new(),
            note: String::new(),
            cfg,
        };
        p.regenerate();
        p
    }

    /// Regenerate the runa pipeline from the current picks (and reset the DAG to Idle).
    fn regenerate(&mut self) {
        self.cfg.name = self.name_buf.trim().to_string();
        if self.cfg.name.is_empty() {
            self.cfg.name = "my-ci".into();
        }
        // Apply the git source buffer (SSH-only; a bad URL is reported, not stored).
        let g = self.git_buf.trim();
        if g.is_empty() {
            self.cfg.source = None;
        } else {
            match GitSource::ssh(g) {
                Ok(s) => {
                    self.cfg.source = Some(s);
                    self.note.clear();
                }
                Err(e) => {
                    self.cfg.source = None;
                    self.note = format!("source rejected: {e}");
                }
            }
        }
        self.pipeline = pipeline_wizard::generate_pipeline(&self.cfg);
        // Idle every stage (a Run lights them).
        self.status = self
            .pipeline
            .stages
            .iter()
            .map(|s| (s.name.clone(), StageStatus::Idle))
            .collect();
        if !self.generated.contains(&self.cfg.name) {
            self.generated.push(self.cfg.name.clone());
        }
    }

    /// Resolve the declared credential injections into a runner-env overlay.
    ///
    /// With the `vault` feature: pull each declared `(name → env_key)` from the encrypted
    /// vault (an UNLOCKED `Session` / `$DWARVES_VAULT_PASSPHRASE`) via
    /// [`dwarves::vault::inject_secret_env`] — **fails closed** (returns `Err`) when the
    /// vault is locked or a secret is missing, so the Run refuses rather than dispatching a
    /// blank credential. Without the feature it is a no-op (empty overlay) and the wizard
    /// runs vault-free. Returns the env overlay AND the KEYS that were injected.
    fn resolve_injected_env(
        &self,
    ) -> Result<(std::collections::BTreeMap<String, String>, Vec<String>), String> {
        let mut env = std::collections::BTreeMap::new();
        if self.cred_injections.is_empty() {
            return Ok((env, Vec::new()));
        }
        #[cfg(feature = "vault")]
        {
            let vault = dwarves::vault::ZnippyVault::default_vault();
            dwarves::vault::inject_secret_env(&vault, &mut env, &self.cred_injections)
                .map_err(|e| format!("credential injection refused: {e}"))?;
            let keys = self
                .cred_injections
                .iter()
                .map(|(_, k)| k.clone())
                .collect();
            Ok((env, keys))
        }
        #[cfg(not(feature = "vault"))]
        {
            // The vault-free build: injections are declared but not pulled (honest).
            let _ = &mut env;
            Ok((env, Vec::new()))
        }
    }

    /// Dispatch the generated pipeline as real jera jobs over a FAKE scheduler (no
    /// process spawn, no deploy) and light the DAG from the outcome. Any declared
    /// credential injections are pulled from the unlocked vault into the runner env first
    /// (fail-closed: a locked vault REFUSES the run — the DAG stays Idle, never green).
    fn run_fake(&mut self) {
        self.injected_keys.clear();
        self.run_refused = false;
        let (extra_env, injected) = match self.resolve_injected_env() {
            Ok(v) => v,
            Err(e) => {
                // Fail closed: refuse the run, leave the DAG Idle, surface the error.
                self.run_refused = true;
                self.last_run = None;
                self.note = e;
                return;
            }
        };
        self.injected_keys = injected;
        match pipeline_wizard::run_preview_with_env(&self.cfg, extra_env) {
            Ok(report) => {
                // The fake scheduler greens every job → every stage Done.
                let done = report.is_green();
                for st in self.status.values_mut() {
                    *st = if done {
                        StageStatus::Done
                    } else {
                        StageStatus::Failed
                    };
                }
                self.last_run = Some(LastRun {
                    dispatched: report.dispatched,
                    green: done,
                    stage_order: report.stage_order.clone(),
                });
                self.note = format!(
                    "ran (dry): {} jera job(s) dispatched · {}{}",
                    report.dispatched,
                    if done { "green" } else { "red" },
                    if self.injected_keys.is_empty() {
                        String::new()
                    } else {
                        format!(
                            " · injected {} vault cred(s) into the runner env",
                            self.injected_keys.len()
                        )
                    },
                );
            }
            Err(e) => self.note = format!("run failed: {e}"),
        }
    }

    /// Save the generated pipeline as `<name>.runa.ron` in the current dir.
    fn save(&mut self) {
        match pipeline_wizard::generate_ron(&self.cfg) {
            Ok(ron) => {
                let name = format!("{}.runa.ron", self.cfg.name);
                let path = match &self.save_dir {
                    Some(dir) => dir.join(&name),
                    None => std::path::PathBuf::from(&name),
                };
                let file = path.display().to_string();
                match std::fs::write(&path, ron) {
                    Ok(()) => {
                        if !self.saved_files.contains(&file) {
                            self.saved_files.push(file.clone());
                        }
                        self.note = format!("saved {file}");
                    }
                    Err(e) => self.note = format!("save failed: {e}"),
                }
            }
            Err(e) => self.note = format!("serialize failed: {e}"),
        }
    }

    /// Declare a credential injection: on a Run, pull the vault secret `name` into the
    /// runner env under `env_key` (idempotent — re-declaring the same pair is a no-op).
    /// Names + keys only; the value is fetched from the vault at run time, never stored.
    pub fn add_cred_injection(&mut self, name: impl Into<String>, env_key: impl Into<String>) {
        let pair = (name.into(), env_key.into());
        if !self.cred_injections.contains(&pair) {
            self.cred_injections.push(pair);
        }
    }

    /// Point `Save` at `dir` (so the `<name>.runa.ron` lands there, not in cwd).
    pub fn set_save_dir(&mut self, dir: impl Into<std::path::PathBuf>) {
        self.save_dir = Some(dir.into());
    }

    /// The reused DAG viewer, lit from the current pipeline + status.
    fn dag_view(&self) -> PipelineView {
        let adapter = RunaPipelineAdapter::new(&self.pipeline).with_status(self.status.clone());
        PipelineView::from_adapter(&adapter)
    }

    fn edge_count(&self) -> usize {
        self.pipeline.stages.iter().map(|s| s.needs.len()).sum()
    }

    /// True iff the generated pipeline round-trips through runa's own parse+validate.
    fn pipeline_valid(&self) -> bool {
        self.pipeline
            .to_ron_string()
            .ok()
            .and_then(|r| dwarves_runa::Pipeline::from_ron_str(&r).ok())
            .is_some()
    }

    // ── the wizard form (left) ────────────────────────────────────────────────
    fn picks_ui(&mut self, ui: &mut egui::Ui) {
        let mut changed = false;
        egui::Grid::new("cicd-wizard-picks")
            .num_columns(2)
            .spacing([12.0, 6.0])
            .show(ui, |ui| {
                let name_lbl = ui.label("Name");
                if ui
                    .text_edit_singleline(&mut self.name_buf)
                    .labelled_by(name_lbl.id)
                    .changed()
                {
                    changed = true;
                }
                ui.end_row();

                let src_lbl = ui.label("Source (SSH)");
                if ui
                    .text_edit_singleline(&mut self.git_buf)
                    .labelled_by(src_lbl.id)
                    .on_hover_text("git@host:owner/repo.git — SSH-only (LAW)")
                    .changed()
                {
                    changed = true;
                }
                ui.end_row();

                // (i) BUILD
                ui.label("Build");
                ui.vertical(|ui| {
                    for kind in ArtifactKind::presets() {
                        let sel = self.cfg.build.kind == kind;
                        if ui.selectable_label(sel, kind.label()).clicked() {
                            self.cfg.build.kind = kind;
                            changed = true;
                        }
                    }
                });
                ui.end_row();

                // (ii) TEST
                ui.label("Test");
                ui.horizontal(|ui| {
                    for (k, lbl) in [
                        (TestKind::Nextest, "nextest"),
                        (TestKind::CargoTest, "cargo test"),
                        (TestKind::None, "none"),
                    ] {
                        if ui.selectable_label(self.cfg.test.kind == k, lbl).clicked() {
                            self.cfg.test.kind = k;
                            changed = true;
                        }
                    }
                });
                ui.end_row();

                // (iii) DEPLOY
                ui.label("Deploy");
                ui.vertical(|ui| {
                    let opts: [(&str, DeployChoice); 4] = [
                        ("none", DeployChoice::None),
                        ("crates.io (gated)", DeployChoice::CratesIo),
                        (
                            "registry",
                            DeployChoice::Registry {
                                name: "ghcr.io/nordisk".into(),
                                tags: vec!["latest".into()],
                            },
                        ),
                        ("pages", DeployChoice::Pages),
                    ];
                    for (lbl, choice) in opts {
                        let sel = std::mem::discriminant(&self.cfg.deploy)
                            == std::mem::discriminant(&choice);
                        if ui.selectable_label(sel, lbl).clicked() {
                            self.cfg.deploy = choice;
                            changed = true;
                        }
                    }
                });
                ui.end_row();

                // (iv) TRIGGER
                ui.label("Trigger");
                ui.horizontal(|ui| {
                    let opts: [(&str, TriggerChoice); 4] = [
                        ("manual", TriggerChoice::Manual),
                        (
                            "push",
                            TriggerChoice::Push {
                                branch: "main".into(),
                            },
                        ),
                        ("tag", TriggerChoice::Tag),
                        ("cron", TriggerChoice::Cron("0 3 * * *".into())),
                    ];
                    for (lbl, choice) in opts {
                        let sel = std::mem::discriminant(&self.cfg.trigger)
                            == std::mem::discriminant(&choice);
                        if ui.selectable_label(sel, lbl).clicked() {
                            self.cfg.trigger = choice;
                            changed = true;
                        }
                    }
                });
                ui.end_row();
            });

        // The fast runner (container-by-default) readout.
        ui.add_space(4.0);
        ui.label(format!(
            "runner: {} · {}{}{}",
            self.cfg.runner.image,
            if self.cfg.runner.containerized {
                "container"
            } else {
                "host"
            },
            if self.cfg.runner.mold { " + mold" } else { "" },
            if self.cfg.runner.sccache {
                " + sccache + holger-cache"
            } else {
                ""
            },
        ));

        if changed {
            self.regenerate();
        }
    }

    // ── manageability: generated pipelines / saved files / runner ─────────────
    fn manage_ui(&mut self, ui: &mut egui::Ui) {
        ui.collapsing("Manage", |ui| {
            ui.label(format!("pipelines this session: {}", self.generated.len()));
            for n in &self.generated {
                ui.small(format!("  • {n}"));
            }
            if !self.saved_files.is_empty() {
                ui.label("saved:");
                for f in &self.saved_files {
                    ui.small(format!("  • {f}"));
                }
            }
            ui.small("running jera jobs + runners: see the Jobs pane (shared jera ledger).");
        });
    }
}

impl Facet for CicdWizardPanel {
    fn title(&self) -> &str {
        "CI/CD"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        ui.heading("CI/CD quick-setup");
        ui.separator();
        // ── THE ACTION ROW IS A FOOTER, NOT THE END OF A COLUMN ──────────────────
        //
        // MEASURED 2026-08-27 on a 1600×1200 canvas: `▶ Run (dry)` laid out at
        // y 1204–1222 and `💾 Save .runa.ron` at the same band — FOUR pixels below a
        // 1200 px surface. `robot_kittest_drives_the_cicd_wizard_real_widgets` had been
        // red on `main` since the pane grew (206 passed / 1 failed on origin/main before
        // any of today's lanes touched it), and it was red for the RIGHT reason: the two
        // buttons that are this pane's whole point were off the rendered surface, so on
        // any 1080p or 1200p window a human could not reach Run or Save at all. There is
        // no ScrollArea above this pane — the shell mounts it as a plain `Panel` — so
        // nothing else was going to bring them back.
        //
        // The fix is the shape, not the canvas size: everything that GROWS (the two
        // columns, the readouts, the manage block) lives in a scroll area that takes the
        // leftover height, and the action row is drawn after it, so it sits at the bottom
        // of whatever surface exists. Enlarging the robot's canvas would have gone green
        // and left the product bug — the pane would simply break again at the next widget.
        let reserved = ACTION_ROW_HEIGHT;
        let body_height = (ui.available_height() - reserved).max(160.0);
        egui::ScrollArea::vertical()
            .id_salt("cicd-wizard-body")
            .max_height(body_height)
            .auto_shrink([false, false])
            .show(ui, |ui| {
                ui.columns(2, |cols| {
                    // Left: the picks.
                    self.picks_ui(&mut cols[0]);
                    // Right: the live DAG (reused viewer).
                    cols[1].label("Live DAG");
                    let mut view = self.dag_view();
                    Facet::ui(&mut view, &mut cols[1]);
                });
                if let Some(r) = &self.last_run {
                    ui.label(format!(
                        "last DRY run (nothing was executed): {} · {} job(s) · stages {}",
                        if r.green { "green" } else { "red" },
                        r.dispatched,
                        r.stage_order.join(" → "),
                    ));
                }
                if !self.note.is_empty() {
                    ui.small(self.note.clone());
                }
                self.manage_ui(ui);
            });
        ui.separator();
        ui.horizontal(|ui| {
            if ui
                .button("▶ Run (dry)")
                .on_hover_text("dispatch the generated pipeline as jera jobs over a fake scheduler — no real deploy")
                .clicked()
            {
                self.run_fake();
            }
            if ui
                .button("💾 Save .runa.ron")
                .on_hover_text("write the generated pipeline to <name>.runa.ron")
                .clicked()
            {
                self.save();
            }
            let valid = self.pipeline_valid();
            ui.label(if valid {
                "pipeline: valid ✓"
            } else {
                "pipeline: INVALID ✗"
            });
        });
    }

    fn state_json(&self) -> serde_json::Value {
        let stages: Vec<_> = self
            .pipeline
            .stages
            .iter()
            .map(|s| {
                serde_json::json!({
                    "name": s.name,
                    "needs": s.needs,
                    "steps": s.steps.len(),
                    "status": format!("{:?}", self.status.get(&s.name).copied().unwrap_or(StageStatus::Idle)),
                })
            })
            .collect();
        serde_json::json!({
            "kind": "cicd_wizard",
            "name": self.cfg.name,
            "source": self.cfg.source.as_ref().map(|s| s.url.clone()),
            "build": self.cfg.build.kind.label(),
            "deploy": format!("{:?}", std::mem::discriminant(&self.cfg.deploy)),
            "pipeline_valid": self.pipeline_valid(),
            // The DAG the viewer renders: one node per stage, one edge per `needs`.
            "dag_nodes": self.pipeline.stages.len(),
            "dag_edges": self.edge_count(),
            "stages": stages,
            "triggers": self.pipeline.triggers.len(),
            "runner": {
                "image": self.cfg.runner.image,
                "containerized": self.cfg.runner.containerized,
                "mold": self.cfg.runner.mold,
                "sccache": self.cfg.runner.sccache,
            },
            "last_run": self.last_run.as_ref().map(|r| serde_json::json!({
                "dispatched": r.dispatched,
                "green": r.green,
                "stage_order": r.stage_order,
            })),
            "generated_count": self.generated.len(),
            "saved": self.saved_files,
            // The credential-injection surface — NAMES + env KEYS only (never a value).
            // `cred_injections` is what the human declared; `injected_env_keys` is what the
            // LAST run actually pulled from the unlocked vault (the cred→pipeline oracle).
            "cred_injections": self.cred_injections.iter()
                .map(|(n, k)| serde_json::json!({ "name": n, "env": k }))
                .collect::<Vec<_>>(),
            "injected_env_keys": self.injected_keys,
            "run_refused": self.run_refused,
            "vault_wired": cfg!(feature = "vault"),
        })
    }

    fn update_json(&mut self, msg_json: &str) {
        // The wizard's control surface — a robot drives it headless as a user:
        //   {"set_name":"x"} · {"set_git":"git@…"} ·
        //   {"set_build":"linux-exe|oci|macos-arm64|wasm|variant:<n>"} ·
        //   {"set_test":"nextest|cargo-test|none"} ·
        //   {"set_deploy":"none|crates-io|registry:<n>|env:<n>|pages"} ·
        //   {"set_trigger":"manual|push:<b>|tag|cron:<e>"} ·
        //   {"generate":true} (regenerate) · {"run":true} (dry dispatch) · {"save":true}
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
            return;
        };
        if let Some(s) = v.get("set_name").and_then(|x| x.as_str()) {
            self.name_buf = s.to_string();
            self.regenerate();
        }
        if let Some(s) = v.get("set_git").and_then(|x| x.as_str()) {
            self.git_buf = s.to_string();
            self.regenerate();
        }
        // Declare a credential injection ({"set_cred":{"name":"X","env":"Y"}}) — pulled
        // from the unlocked vault into the runner env on a Run. Names + keys only.
        if let Some(c) = v.get("set_cred").and_then(|x| x.as_object()) {
            if let (Some(name), Some(env)) = (
                c.get("name").and_then(|x| x.as_str()),
                c.get("env").and_then(|x| x.as_str()),
            ) {
                self.add_cred_injection(name, env);
            }
        }
        if v.get("clear_creds").and_then(|x| x.as_bool()) == Some(true) {
            self.cred_injections.clear();
            self.injected_keys.clear();
        }
        // Point Save at a directory ({"set_save_dir":"/path"}) — the artifact lands there.
        if let Some(d) = v.get("set_save_dir").and_then(|x| x.as_str()) {
            self.set_save_dir(d);
        }
        // Apply each provided pick individually (reusing the CLI's exact flag grammar
        // via the shared `parse_*` helpers — one grammar, two front doors).
        let mut touched = false;
        if let Some(k) = v.get("set_build").and_then(|x| x.as_str()) {
            if let Ok(kind) = pipeline_wizard::parse_build(k) {
                self.cfg.build.kind = kind;
                touched = true;
            }
        }
        if let Some(k) = v.get("set_test").and_then(|x| x.as_str()) {
            if let Ok(kind) = pipeline_wizard::parse_test(k) {
                self.cfg.test.kind = kind;
                touched = true;
            }
        }
        if let Some(k) = v.get("set_deploy").and_then(|x| x.as_str()) {
            if let Ok(d) = pipeline_wizard::parse_deploy(k) {
                self.cfg.deploy = d;
                touched = true;
            }
        }
        if let Some(k) = v.get("set_trigger").and_then(|x| x.as_str()) {
            if let Ok(t) = pipeline_wizard::parse_trigger(k) {
                self.cfg.trigger = t;
                touched = true;
            }
        }
        if touched {
            self.regenerate();
        }
        if v.get("generate").and_then(|x| x.as_bool()) == Some(true) {
            self.regenerate();
        }
        if v.get("run").and_then(|x| x.as_bool()) == Some(true) {
            self.run_fake();
        }
        if v.get("save").and_then(|x| x.as_bool()) == Some(true) {
            self.save();
        }
    }
}

/// **The wizard seam.** Build the CI/CD quick-setup pane as a `Box<dyn Panel>` a host
/// (dwarves-gui) drops into a tab. Opens on the minimal-viable default pipeline.
pub fn cicd_wizard_panel() -> Box<dyn Panel> {
    Box::new(CicdWizardPanel::new("my-ci"))
}

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

    // ROBOT-UI (drive-as-a-user): pick OCI build + crates.io deploy + a push trigger
    // through the pane's JSON control surface, then RUN — asserting the generated
    // pipeline is valid, the DAG renders (nodes+edges), and the run dispatched real
    // jera jobs and greened the DAG. RED if the wizard stops generating a valid
    // pipeline, the DAG stops rendering, or a run stops dispatching.
    #[test]
    fn robot_drives_wizard_generates_valid_pipeline_dag_and_run() {
        let mut p = CicdWizardPanel::new("robot-ci");

        // Drive the four picks + an SSH source as a user would.
        Facet::update_json(&mut p, r#"{"set_git":"git@forge.example:nordisk/korp.git"}"#);
        Facet::update_json(&mut p, r#"{"set_build":"oci"}"#);
        Facet::update_json(&mut p, r#"{"set_deploy":"crates-io"}"#);
        Facet::update_json(&mut p, r#"{"set_trigger":"push:main"}"#);

        let s = Facet::state_json(&p);
        // A valid runa pipeline is generated.
        assert_eq!(s["pipeline_valid"], serde_json::json!(true), "state: {s}");
        assert_eq!(s["source"], "git@forge.example:nordisk/korp.git");
        // The DAG renders: build → test → deploy = 3 nodes, 2 `needs` edges.
        assert_eq!(s["dag_nodes"], serde_json::json!(3), "state: {s}");
        assert_eq!(s["dag_edges"], serde_json::json!(2), "state: {s}");

        // A Run dispatches real jera jobs (fake scheduler, no deploy) and greens.
        Facet::update_json(&mut p, r#"{"run":true}"#);
        let s = Facet::state_json(&p);
        let last = &s["last_run"];
        assert!(
            last["dispatched"].as_u64().unwrap_or(0) > 0,
            "a run must dispatch jera jobs: {s}"
        );
        assert_eq!(last["green"], serde_json::json!(true), "state: {s}");

        // A dead/blank wizard (no stages) would be the RED state the oracle rejects.
        let blank_is_impossible = s["dag_nodes"].as_u64().unwrap_or(0) >= 1;

        facett_core::testmatrix::emit(
            "dwarves-embed",
            "cicd_wizard_generates_dag_and_runs",
            s["pipeline_valid"] == serde_json::json!(true)
                && last["green"] == serde_json::json!(true)
                && blank_is_impossible,
            "CI/CD wizard pane driven as a user (pick build/test/deploy/trigger + SSH source) GENERATES a valid runa pipeline, RENDERS the stage DAG (3 nodes / 2 edges for build→test→deploy), and a Run DISPATCHES real jera jobs that green the DAG — RED if it generates nothing/invalid or a run dispatches no jobs",
        );
    }

    // The credential-injection SURFACE (names + env keys only, never a value): a human
    // declares `{"set_cred":{...}}`, the wizard shows it in `cred_injections`, and a Run
    // records the injected KEYS. In the vault-free build (the default test build) nothing
    // is pulled (honest: `injected_env_keys` empty, `vault_wired:false`) and the run still
    // greens — the full unlocked-vault pull is proven by the dwarves-gui journey.
    #[test]
    fn cred_injection_surface_is_names_and_keys_only() {
        let mut p = CicdWizardPanel::new("cred-ci");
        Facet::update_json(
            &mut p,
            r#"{"set_cred":{"name":"crates-io","env":"CARGO_REGISTRY_TOKEN"}}"#,
        );
        let s = Facet::state_json(&p);
        let creds = s["cred_injections"].as_array().unwrap();
        assert_eq!(creds.len(), 1);
        assert_eq!(creds[0]["name"], "crates-io");
        assert_eq!(creds[0]["env"], "CARGO_REGISTRY_TOKEN");
        // NEVER a value anywhere in the pane state.
        let whole = serde_json::to_string(&s).unwrap();
        assert!(
            !whole.contains("value"),
            "no secret value in the wizard state: {whole}"
        );

        assert_eq!(s["vault_wired"], serde_json::json!(cfg!(feature = "vault")));

        Facet::update_json(&mut p, r#"{"run":true}"#);
        let s = Facet::state_json(&p);
        #[cfg(not(feature = "vault"))]
        {
            // vault-free build: injections are declared but not pulled; run still greens.
            assert_eq!(s["run_refused"], serde_json::json!(false));
            assert!(s["injected_env_keys"].as_array().unwrap().is_empty());
            assert_eq!(s["last_run"]["green"], serde_json::json!(true));
        }
        #[cfg(feature = "vault")]
        {
            // vault-wired build with NO unlocked vault here ⇒ the run FAILS CLOSED (it
            // refuses rather than dispatch a blank credential). The unlocked-vault pull is
            // proven by the dwarves-gui journey (which unlocks a real Session).
            assert_eq!(
                s["run_refused"],
                serde_json::json!(true),
                "locked vault ⇒ fail closed: {s}"
            );
            assert!(s["injected_env_keys"].as_array().unwrap().is_empty());
        }
    }

    // The DEFAULT wizard is the minimal-viable happy path: build → test, valid.
    #[test]
    fn default_wizard_is_minimal_viable() {
        let p = CicdWizardPanel::new("d");
        let s = Facet::state_json(&p);
        assert_eq!(s["pipeline_valid"], serde_json::json!(true));
        assert!(s["dag_nodes"].as_u64().unwrap() >= 2, "build + test: {s}");
        assert_eq!(s["runner"]["mold"], serde_json::json!(true));
        assert_eq!(s["runner"]["containerized"], serde_json::json!(true));
    }
}