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

nordisk/nornir

src/viz/release_tab.rs

//! 🚀 **Release** tab — the release-op DAG (`release_events`) as a viz surface.
//!
//! Two panes over the same data, both driven by the warehouse
//! [`release_events`](crate::warehouse::release_events) table:
//!
//!   1. **Op-log** (B3) — a streaming worklog, one line per
//!      `component × op × phase` boundary, prefixed by the component it acts on:
//!      `[znippy] test start`, `[znippy] test end ok (1.2s)`, `[holger] gate end ok`.
//!      Grouped by `run_id`, **newest run on top**, `seq`-ordered within a run.
//!   2. **Lit-up dep-graph** (B4) — the rectangular dep-graph (reusing
//!      [`super::graph`]'s node/edge geometry) where each node is **coloured by
//!      its latest `release_events` status for the selected run**: running = amber
//!      pulse, ok = green, fail = red, idle = dim. As a run progresses (the table
//!      gains rows), the graph lights up.
//!
//! Read path: **local** mode opens the warehouse read-only and calls
//! [`query_release_events`]. **Remote** mode reads the SAME rows over the
//! `Viz.ReleaseEvents` gRPC (the warehouse lives on the server, which holds the
//! redb lock), decoding them into the identical [`ReleaseEventRow`] — so the
//! op-log + lit-up dep-graph render byte-identically whether the viz is embedded
//! or thin. This is the same local-or-remote split the other tabs use.

use std::collections::{BTreeMap};
use std::path::PathBuf;
use std::time::Instant;

use eframe::egui::{
    self, Align2, Color32, CornerRadius, FontId, Pos2, Rect, RichText, ScrollArea, Sense, Stroke,
    Vec2,
};

use crate::release::doctor::{self, DepPolicy, DoctorReport};
use crate::warehouse::iceberg::IcebergWarehouse;
use crate::warehouse::release_events::{
    EventSelector, ReleaseEventRow, query_release_events, status,
};
use nornir_release_core::edition::{self, EditionReport};

use super::facett_theme::{AMBER, GREEN, RED, Theme};

/// Where the release events come from (mirrors the other tabs' local/remote split).
enum Src {
    Local(PathBuf),
    /// Thin mode — read `release_events` over the `Viz.ReleaseEvents` RPC. The
    /// `workspace` selects which served workspace's events (the gRPC header).
    Remote {
        endpoint: String,
        token: String,
        workspace: String,
    },
}

/// One "run" — the rows of a single `run_id`, newest run on top.
#[derive(Clone)]
struct RunGroup {
    run_id: String,
    /// `seq`-ordered rows for this run.
    rows: Vec<ReleaseEventRow>,
    /// Latest `ts_micros` in the run — used to sort runs newest-first.
    latest_ts: i64,
}

// The cached **Gate / Doctor** model is the SHARED [`super::gate::GateModel`] —
// the same computation + `gate_json` shape the 🧬 nornir RELEASE DASHBOARD reuses.
use super::gate::GateModel;

pub struct ReleaseTabState {
    src: Src,
    loaded: bool,
    error: Option<String>,
    /// Inputs for the **Gate / Doctor** section: the workspace checkout root and
    /// the repos to analyze. Only LOCAL mode has a checkout to scan; remote mode
    /// leaves this empty (the gate section then shows a "local-only" note).
    gate_repos: Vec<(String, PathBuf)>,
    /// Forbidden-version policy fed to the doctor's skew analysis (e.g. `arrow 56`).
    gate_policy: DepPolicy,
    /// Cached gate/doctor model — computed in `load()`/`reload()`, NOT per repaint.
    gate: Option<GateModel>,
    /// Set when computing the gate model failed (rendered in the section).
    gate_error: Option<String>,
    /// Whether the opt-in dynamic edition lint pass (`cargo check`, multi-second)
    /// has been requested via the button; folded into the next gate recompute.
    edition_lint_requested: bool,
    /// All runs found, newest first.
    runs: Vec<RunGroup>,
    /// The selected run_id (the graph + op-log scope). Defaults to newest.
    selected_run: Option<String>,
    /// Pulse phase for the amber "running" glow (advances every frame).
    started_at: Instant,
    /// Active palette — every colour below derives from it so a palette switch
    /// re-skins the whole pane.
    theme: Theme,
    /// Two-step confirm latch for the HEAVY release-grade operation (runs the
    /// full release gate across the build order server-side — release-grade).
    run_armed: bool,
    /// Last `Ops.RunRelease` result (rendered + reported in state_json).
    run_result: Option<Result<super::remote::OpRunResult, String>>,
    /// The **crate publish-DAG** pane (facett-graphview): nodes = crates, edges =
    /// depends-on, node colour = registry state, wave badge, cycle ring. Built in
    /// `load()` (local mode only — needs a workspace checkout to scan).
    depdag: super::release_depdag::ReleaseDepDagPane,
    /// The assembled dep-DAG model, cached so the transitive/probe toggles rebuild
    /// the pane without re-scanning the workspace.
    depdag_model: Option<nornir_release_core::depdag::ReleaseDepDag>,
    /// Registry probe results (crate → max published version), filled on demand by
    /// the "⬇ probe registry" button. Empty ⇒ states show `unknown` (offline).
    depdag_published: BTreeMap<String, Option<String>>,
    /// Whether the DEEP (symbol-derived) edge set has been loaded from the
    /// warehouse yet (lazy — only on the first THIN→DEEP flip, since it reads the
    /// warehouse's `call_edges`). Reset with the model.
    depdag_deep_loaded: bool,
}

impl ReleaseTabState {
    pub fn local(root: PathBuf) -> Self {
        Self::with(Src::Local(root))
    }

    pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
        Self::with(Src::Remote {
            endpoint,
            token,
            workspace,
        })
    }

    /// Re-point the thin-mode RPC at a new workspace (the picker switched). No-op
    /// when local; drops the cache so the next draw refetches.
    pub fn set_workspace(&mut self, workspace: String) {
        if let Src::Remote { workspace: w, .. } = &mut self.src {
            *w = workspace;
        }
        self.reload();
    }

    /// Wire the **Gate / Doctor** section's inputs: the workspace root + repo names
    /// (local mode only). Each repo's checkout is `root/<repo>`; repos without a
    /// `Cargo.toml` are skipped. The forbidden-version policy mirrors the CLI's
    /// `--forbid crate=version` (e.g. `arrow 56`). Drops any cached model so the
    /// next `load()` recomputes.
    pub fn set_gate_inputs(&mut self, root: &std::path::Path, repos: &[String], policy: DepPolicy) {
        self.gate_repos = repos
            .iter()
            .map(|name| (name.clone(), root.join(name)))
            .filter(|(_, p)| p.join("Cargo.toml").exists())
            .collect();
        self.gate_policy = policy;
        self.gate = None;
        self.gate_error = None;
        self.loaded = false;
    }

    fn with(src: Src) -> Self {
        Self {
            src,
            loaded: false,
            error: None,
            gate_repos: Vec::new(),
            gate_policy: DepPolicy::default(),
            gate: None,
            gate_error: None,
            edition_lint_requested: false,
            runs: Vec::new(),
            selected_run: None,
            started_at: Instant::now(),
            theme: Theme::default(),
            run_armed: false,
            run_result: None,
            depdag: super::release_depdag::ReleaseDepDagPane::default(),
            depdag_model: None,
            depdag_published: BTreeMap::new(),
            depdag_deep_loaded: false,
        }
    }

    /// Assemble the crate publish-DAG from the gate repos (local mode only) + build
    /// the facett pane. Offline: local versions parsed from manifests, registry
    /// states filled only after an explicit probe (`depdag_published`). Cheap enough
    /// to run once per load; gated on a non-empty checkout set.
    fn compute_depdag(&mut self) {
        if self.gate_repos.is_empty() {
            return;
        }
        let graphs: Vec<crate::release::doctor::RepoGraph> = self
            .gate_repos
            .iter()
            .filter_map(|(n, p)| crate::release::doctor::gather_repo_graph(n, p).ok())
            .collect();
        if graphs.is_empty() {
            return;
        }
        let local = crate::release::doctor::local_crate_versions(&self.gate_repos);
        let dag = nornir_release_core::depdag::ReleaseDepDag::assemble(
            &graphs,
            &local,
            &self.depdag_published,
        );
        self.depdag.build(&dag);
        self.depdag_model = Some(dag);
    }

    /// Probe the registry for every crate's max published version (best-effort,
    /// network), then reassemble the dep-DAG so nodes light up will-publish / desync
    /// / already-published / unpublished. The default crates.io sparse-index backend.
    fn probe_registry(&mut self) {
        let Some(model) = &self.depdag_model else {
            return;
        };
        let backend = nornir_release_core::registry::CratesIoBackend;
        use nornir_release_core::registry::RegistryBackend as _;
        let mut published: BTreeMap<String, Option<String>> = BTreeMap::new();
        for n in &model.nodes {
            published.insert(n.name.clone(), backend.published_max_version(&n.name));
        }
        self.depdag_published = published;
        self.compute_depdag();
    }

    /// Load the **DEEP** (symbol-derived) crate→crate edge set from the code
    /// room and hand it to the pane: the reader's `Viz.Knowledge` answer carries
    /// the local crate → local crate edges (CALLS ∪ USES_TYPE at item level, with
    /// a few of the names that justify each) — over gRPC in thin mode, from the
    /// workspace's own code room in fat mode. Lazy (on the first THIN→DEEP flip)
    /// and once (guarded by `depdag_deep_loaded`). A reader that cannot answer
    /// marks the set UNAVAILABLE with its reason — never "no deep edges".
    fn compute_deep_edges(&mut self) {
        if self.depdag_deep_loaded {
            return;
        }
        self.depdag_deep_loaded = true;
        let answer = match &self.src {
            Src::Local(root) => super::remote::local_surface(root, "", "Viz.Knowledge", &serde_json::json!({ "workspace": "" })),
            Src::Remote { endpoint, token, workspace } => super::remote::fetch_knowledge(endpoint, token, workspace),
        };
        match answer.map_err(|e| format!("{e:#}")).and_then(|v| deep_edges_from_answer(&v)) {
            Ok(edges) => self.depdag.set_deep_edges(edges),
            Err(why) => self.depdag.mark_deep_unavailable(why),
        }
    }

    /// The 🚀 Release pane's operation strip: **Release (heavy gate)** — the
    /// full release-grade gate across the build order. HEAVY: confirm + a
    /// "long-running / release-grade" warning before firing `Ops.RunRelease`,
    /// which persists `release_events` so this very pane lights up. Remote-only.
    /// It runs each repo's CONFIGURED `[gates]` suite — the same suite
    /// `Release.GateAll` (the 🚦 pane) runs, via `server_grpc::run_gate_suite`.
    /// There is no CLI twin: the release verbs moved to `dwarves release`.
    fn draw_release_op(&mut self, ui: &mut egui::Ui) {
        let Src::Remote {
            endpoint,
            token,
            workspace,
        } = &self.src
        else {
            ui.label(
                egui::RichText::new("release via `nornir release` CLI (local mode)")
                    .color(self.theme.text_dim),
            );
            return;
        };
        let (endpoint, token, workspace) = (endpoint.clone(), token.clone(), workspace.clone());
        if !self.run_armed {
            if ui
                .button("🚀 Release (heavy gate)")
                .on_hover_text("HEAVY / release-grade — runs each repo's CONFIGURED [gates] suite across the build order server-side (Ops.RunRelease), the same suite the 🚦 Release-gates pane runs")
                .clicked()
            {
                self.run_armed = true;
            }
        } else {
            ui.colored_label(AMBER, "⚠ heavy / release-grade — confirm?");
            if ui.button("✓ run release gate").clicked() {
                self.run_result = Some(
                    super::remote::run_release(&endpoint, &token, "", &workspace)
                        .map_err(|e| format!("{e:#}")),
                );
                self.run_armed = false;
                self.reload();
            }
            if ui.button("✕ cancel").clicked() {
                self.run_armed = false;
            }
        }
        match &self.run_result {
            Some(Ok(r)) => {
                ui.colored_label(if r.ok { GREEN } else { RED }, &r.summary);
            }
            Some(Err(e)) => {
                ui.colored_label(RED, e);
            }
            None => {}
        }
    }

    /// Swap the active palette so the next draw re-skins every colour.
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// Test-only: inject release-op rows directly (no warehouse on disk), marking
    /// the tab loaded so `draw`/`state_json` render the injected DAG. Mirrors
    /// the app's `inject_timeline_for_test` for this surface.
    #[doc(hidden)]
    pub fn inject_for_test(&mut self, rows: Vec<ReleaseEventRow>) {
        self.runs = group_runs(rows);
        self.selected_run = self.runs.first().map(|r| r.run_id.clone());
        self.loaded = true;
        self.error = None;
    }

    /// Test-only: inject a Gate / Doctor model directly (no checkout to scan), so
    /// the robot/inject-assert harness can read the `"gate"` block out of
    /// `state_json()`. Mirrors `inject_for_test` for the gate surface. Marks the
    /// tab loaded and emits the trace, exactly as `compute_gate` would.
    #[doc(hidden)]
    pub fn inject_gate_for_test(&mut self, doctor: DoctorReport, edition: EditionReport) {
        // Mark configured so `draw_gate_section` renders the model (not the
        // local-only note); use a sentinel repo entry the doctor would have filled.
        if self.gate_repos.is_empty() {
            self.gate_repos.push(("nornir".into(), PathBuf::from(".")));
        }
        self.gate = Some(GateModel::new(doctor, edition));
        self.gate_error = None;
        self.loaded = true;
        self.emit_trace();
    }

    /// Test hook: assemble + build the crate publish-DAG pane from the configured
    /// gate repos (no draw frame), so the inject-and-assert harness can read the
    /// `"depdag"` block out of `state_json()`. Returns the pane's node count.
    #[doc(hidden)]
    pub fn build_depdag_for_test(&mut self) -> usize {
        self.compute_depdag();
        self.depdag.node_count()
    }

    /// Test hook: do what the "deep" toggle does WITHOUT a warehouse — inject the
    /// DEEP edge set directly, flip the pane into DEEP mode, and rebuild, so a
    /// headless test can assert the `"depdag"` block reports `mode == "deep"` over
    /// the injected symbol edges. Requires a prior `build_depdag_for_test`.
    #[doc(hidden)]
    pub fn toggle_deep_for_test(
        &mut self,
        deep_edges: Vec<nornir_release_core::depdag::CrateDepEdge>,
    ) {
        self.depdag.set_deep_edges(deep_edges);
        self.depdag.toggle_mode();
        if let Some(m) = self.depdag_model.clone() {
            self.depdag.build(&m);
        }
    }

    /// Re-scope (workspace switch / reload): drop the cache so the next draw reloads.
    pub fn reload(&mut self) {
        self.loaded = false;
        self.error = None;
        self.runs.clear();
        self.selected_run = None;
        self.gate = None;
        self.gate_error = None;
        // Preserve any registry probe across a reload, but drop the built pane so the
        // next load() reassembles from a fresh workspace scan.
        self.depdag = super::release_depdag::ReleaseDepDagPane::default();
        self.depdag_model = None;
        self.depdag_deep_loaded = false;
    }

    /// Compute (and cache) the **Gate / Doctor** model: the doctor advisory + the
    /// edition gate. Runs ONCE per load/reload (not per repaint). The edition
    /// dynamic lint pass (`cargo check`) only runs when `edition_lint_requested`
    /// — otherwise we skip it and use only the cheap `static_findings`, per the
    /// perf caveat. Local mode only (remote has no checkout to scan).
    fn compute_gate(&mut self) {
        if self.gate.is_some() || self.gate_repos.is_empty() {
            return;
        }
        // The dynamic `cargo check` edition lint pass is opt-in — the always-on
        // view uses static-only. The SHARED gate model does the same computation
        // the 🧬 dashboard reuses.
        match GateModel::compute(
            &self.gate_repos,
            &self.gate_policy,
            self.edition_lint_requested,
        ) {
            Ok(m) => {
                self.gate = Some(m);
                self.gate_error = None;
                self.emit_trace();
            }
            Err(e) => self.gate_error = Some(e),
        }
    }

    /// Emit the gate/doctor model as a machine-readable trace (the twin of what the
    /// pane paints), and self-report a `functional_status` row so a pane that can't
    /// build its model becomes a RED matrix row. No-op for trace when unconfigured.
    fn emit_trace(&self) {
        let model = self.gate_json();
        super::trace::emit_out("release.gate.build", &model);
        super::trace::emit_end(
            "release.gate",
            &serde_json::json!({
                "skew_count": model["skew_count"],
                "held_back": model["held_back"],
                "cycle_advice": model["cycle_advice"],
                "edition_clean": model["edition_clean"],
            }),
        );
        #[cfg(feature = "testmatrix")]
        {
            // ok ⇔ the pane built a gate model (or had no inputs to build one) AND
            // did not error. A broken model build → RED row.
            let built = self.gate.is_some() || self.gate_repos.is_empty();
            crate::selftest::emit(
                "viz/release (gate/doctor)",
                "release_gate_model_built",
                built && self.gate_error.is_none(),
                &format!(
                    "skew={} held_back={} cycle_advice={} edition_clean={} error={:?}",
                    model["skew_count"],
                    model["held_back"].as_array().map(|a| a.len()).unwrap_or(0),
                    model["cycle_advice"],
                    model["edition_clean"],
                    self.gate_error,
                ),
            );
        }
    }

    /// Read every release event from the warehouse and group by run (local only).
    fn load(&mut self) {
        if self.loaded {
            return;
        }
        self.loaded = true;
        // Build the cached Gate / Doctor model (once per load; perf-sensitive code
        // paths are gated — dynamic `cargo check` is opt-in).
        self.compute_gate();
        // Assemble the crate publish-DAG (facett dep-graph) alongside (local only).
        self.compute_depdag();
        let rows = match &self.src {
            Src::Local(root) => {
                // Read-only + lock-tolerant: coexist with a live server holding
                // the catalog lock (the snapshot-copy fallback) exactly as the
                // `nornir release events` CLI read path does.
                match IcebergWarehouse::open_read_only(root)
                    .and_then(|wh| wh.block_on(query_release_events(&wh, &EventSelector::All)))
                {
                    Ok(rows) => rows,
                    Err(e) => {
                        self.error = Some(format!("{e:#}"));
                        return;
                    }
                }
            }
            Src::Remote {
                endpoint,
                token,
                workspace,
            } => {
                // Thin mode: read the SAME rows over Viz.ReleaseEvents (the server
                // holds the warehouse lock). Decoded into ReleaseEventRow, so the
                // grouping/graph below is source-agnostic.
                match super::remote::fetch_release_events(endpoint, token, workspace) {
                    Ok(rows) => rows,
                    Err(e) => {
                        self.error = Some(format!("{e:#}"));
                        return;
                    }
                }
            }
        };
        self.runs = group_runs(rows);
        self.selected_run = self.runs.first().map(|r| r.run_id.clone());
    }

    /// The currently-selected run (or the newest if none chosen yet).
    fn current(&self) -> Option<&RunGroup> {
        let sel = self.selected_run.as_deref();
        sel.and_then(|id| self.runs.iter().find(|r| r.run_id == id))
            .or_else(|| self.runs.first())
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        let theme = self.theme;
        self.load();

        if let Some(err) = self.error.clone() {
            ui.colored_label(RED, format!("release_events read failed:\n{err}"));
            return;
        }
        if self.runs.is_empty() {
            ScrollArea::vertical()
                .auto_shrink([false, false])
                .show(ui, |ui| {
                    ui.vertical_centered(|ui| {
                        ui.add_space(20.0);
                        ui.heading("🚀 Release — no release runs recorded yet");
                        ui.label("Every release run records its op DAG here:");
                        ui.monospace("dwarves release                        # the orchestrator");
                        ui.monospace("nornir release events <run-id|repo>   # CLI twin");
                        ui.add_space(12.0);
                        ui.horizontal(|ui| self.draw_release_op(ui));
                    });
                    ui.add_space(16.0);
                    ui.separator();
                    // Even with no runs yet, the Gate / Doctor advisory is useful.
                    self.draw_gate_section(ui);
                });
            return;
        }

        // ── run picker (newest first) ────────────────────────────────────────
        let runs: Vec<(String, usize, i64)> = self
            .runs
            .iter()
            .map(|r| (r.run_id.clone(), r.rows.len(), r.latest_ts))
            .collect();
        egui::Panel::top("release_controls").show_inside(ui, |ui| {
            ui.horizontal_wrapped(|ui| {
                ui.label("run:");
                let sel = self
                    .selected_run
                    .clone()
                    .unwrap_or_else(|| runs.first().map(|r| r.0.clone()).unwrap_or_default());
                egui::ComboBox::from_id_salt("release_run")
                    .selected_text(short_run(&sel))
                    .show_ui(ui, |ui| {
                        for (id, n, _) in &runs {
                            ui.selectable_value(
                                &mut self.selected_run,
                                Some(id.clone()),
                                format!("{} · {n} events", short_run(id)),
                            );
                        }
                    });
                if ui
                    .button("↻ reload")
                    .on_hover_text("re-read release_events")
                    .clicked()
                {
                    self.reload();
                }
                ui.separator();
                self.draw_release_op(ui);
                ui.separator();
                legend(ui, &theme);
            });
        });

        // Resolve the selected run AFTER the picker may have changed it.
        let Some(run) = self.current().cloned() else {
            return;
        };

        // Latest status per component for THIS run (drives the graph colours).
        let comp_status = component_status(&run.rows);

        // ── B4: the lit-up dep-graph (top half) ──────────────────────────────
        let graph_h = (ui.available_height() * 0.5).max(180.0);
        egui::Panel::top("release_graph")
            .resizable(true)
            .default_size(graph_h)
            .show_inside(ui, |ui| {
                ScrollArea::both()
                    .auto_shrink([false, false])
                    .show(ui, |ui| {
                        self.draw_lit_graph(ui, &run, &comp_status);
                    });
            });

        // ── B3: the op-log (bottom half) ─────────────────────────────────────
        egui::CentralPanel::default().show_inside(ui, |ui| {
            ui.horizontal(|ui| {
                ui.strong(format!("run {}", short_run(&run.run_id)));
                ui.separator();
                let overall = run_outcome(&run.rows);
                let (col, txt) = match overall {
                    RunOutcome::Running => (AMBER, "● running"),
                    RunOutcome::Ok => (GREEN, "✓ ok"),
                    RunOutcome::Fail => (RED, "✗ failed"),
                };
                ui.colored_label(col, txt);
                ui.separator();
                ui.weak(format!("{} events", run.rows.len()));
            });
            ui.separator();
            ScrollArea::vertical()
                .auto_shrink([false, false])
                .stick_to_bottom(true)
                .show(ui, |ui| {
                    for r in &run.rows {
                        op_log_row(ui, &theme, r);
                    }
                    ui.add_space(8.0);
                    ui.separator();
                    // The Gate / Doctor advisory beneath the op-log (cached model).
                    self.draw_gate_section(ui);
                });
        });

        // Keep the amber pulse animating while any op is still running.
        if comp_status.values().any(|s| *s == status::RUNNING) {
            ui.ctx()
                .request_repaint_after(std::time::Duration::from_millis(80));
        }
    }

    /// B4 — the rectangular dep-graph, nodes coloured by latest release status.
    fn draw_lit_graph(
        &self,
        ui: &mut egui::Ui,
        run: &RunGroup,
        comp_status: &BTreeMap<String, String>,
    ) {
        let theme = self.theme;
        // Components (graph nodes) = every component named in this run, ordered
        // deps-first by depends_on (a stable toposort; falls back to first-seen).
        let order = topo_order(run);
        if order.is_empty() {
            ui.label("(no components in this run)");
            return;
        }

        const NODE_W: f32 = 150.0;
        const NODE_H: f32 = 52.0;
        const COL_GAP: f32 = 210.0;
        const LEFT_PAD: f32 = 24.0;
        const TOP_PAD: f32 = 40.0;

        let col_of: BTreeMap<&str, usize> = order
            .iter()
            .enumerate()
            .map(|(i, c)| (c.as_str(), i))
            .collect();
        let pos_for = |rect: Rect, comp: &str| -> Pos2 {
            let col = *col_of.get(comp).unwrap_or(&0);
            Pos2::new(
                rect.left() + LEFT_PAD + col as f32 * COL_GAP,
                rect.top() + TOP_PAD,
            )
        };

        let needed_w = LEFT_PAD + order.len() as f32 * COL_GAP + NODE_W;
        let canvas = Vec2::new(
            ui.available_width().max(needed_w),
            (NODE_H + TOP_PAD * 2.0).max(160.0),
        );
        let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, CornerRadius::ZERO, theme.bg);

        // amber pulse 0..1 for the running glow.
        let pulse = {
            let t = self.started_at.elapsed().as_secs_f32();
            0.5 + 0.5 * (t * 3.0).sin()
        };

        // edges (consumer → producer), drawn under nodes.
        for comp in &order {
            if let Some(deps) = depends_on_of(run, comp) {
                let from = pos_for(rect, comp) + Vec2::new(0.0, NODE_H / 2.0);
                for dep in deps {
                    if !col_of.contains_key(dep.as_str()) {
                        continue;
                    }
                    let to = pos_for(rect, dep) + Vec2::new(NODE_W, NODE_H / 2.0);
                    painter.line_segment([from, to], Stroke::new(2.0_f32, theme.edge));
                }
            }
        }

        // nodes.
        for comp in &order {
            let node_pos = pos_for(rect, comp);
            let node_rect = Rect::from_min_size(node_pos, Vec2::new(NODE_W, NODE_H));
            let st = comp_status
                .get(comp.as_str())
                .map(String::as_str)
                .unwrap_or("idle");
            let base = lit_color(&theme, st);
            // Running nodes pulse between dim and full amber.
            let fill = if st == status::RUNNING {
                let a = (110.0 + 110.0 * pulse) as u8;
                AMBER.linear_multiply(a as f32 / 255.0)
            } else {
                theme.node_fill
            };
            painter.rect_filled(node_rect, CornerRadius::same(6), fill);
            painter.rect_stroke(
                node_rect,
                CornerRadius::same(6),
                Stroke::new(2.5_f32, base),
                egui::StrokeKind::Inside,
            );
            painter.text(
                node_pos + Vec2::new(NODE_W / 2.0, 12.0),
                Align2::CENTER_TOP,
                comp,
                FontId::proportional(14.0),
                theme.text,
            );
            painter.text(
                node_pos + Vec2::new(NODE_W / 2.0, 30.0),
                Align2::CENTER_TOP,
                st,
                FontId::monospace(11.0),
                base,
            );
        }
    }

    /// The structured **Gate / Doctor** block folded into `state_json` — what the
    /// robot test (track D) asserts on. Stable keys regardless of whether the model
    /// is present (remote / unconfigured → `present: false`, zero counts).
    fn gate_json(&self) -> serde_json::Value {
        // Delegate to the SHARED gate model's `gate_json` shape (the same the 🧬
        // dashboard ships), so the two surfaces can never drift.
        match &self.gate {
            Some(m) => m.gate_json(self.gate_error.as_deref()),
            None => super::gate::gate_json_absent(self.gate_error.as_deref()),
        }
    }

    /// The **Gate / Doctor** section of the Release pane: dirty trees, external-dep
    /// skew (with the ⛔ transitive-pin rows), cycle-break advice, and the
    /// edition-2024 gate result. Reads only the CACHED model (no recompute per
    /// frame); the dynamic edition lint pass is behind a button.
    /// The **crate publish-DAG** section: the facett dep-graph pane (pan/zoom, node
    /// status colour, wave badge, cycle ring) + its controls (transitive toggle,
    /// registry probe). Local mode only — needs a workspace checkout to scan.
    fn draw_depdag_section(&mut self, ui: &mut egui::Ui) {
        let theme = self.theme;
        egui::CollapsingHeader::new("🧬 Crate publish-DAG")
            .default_open(false)
            .show(ui, |ui| {
                if !self.depdag.is_built() {
                    ui.label(
                        RichText::new(
                            "crate publish-DAG is local-only — it maps the crate dependency graph the release doctor topo-sorts (nodes = crates, edges = depends-on)",
                        )
                        .color(theme.text_dim),
                    );
                    return;
                }
                ui.horizontal_wrapped(|ui| {
                    // THIN (cargo graph) ⇄ DEEP (symbol-derived call-graph rollup).
                    let deep = self.depdag.mode == super::release_depdag::DepDagMode::Deep;
                    if ui
                        .selectable_label(deep, "deep")
                        .on_hover_text("DEEP: crate→crate edges rolled up from the warehouse call-graph (symbol-derived). Off = the THIN cargo-metadata dependency graph.")
                        .clicked()
                    {
                        // Lazily load the DEEP edge set on the first flip into it.
                        self.compute_deep_edges();
                        self.depdag.toggle_mode();
                        if let Some(m) = self.depdag_model.clone() {
                            self.depdag.build(&m);
                        }
                    }
                    if deep {
                        ui.weak(format!("{} symbol edges", self.depdag.deep_edge_count()));
                    }
                    if ui
                        .selectable_label(self.depdag.show_transitive, "transitive")
                        .on_hover_text("Synthesise transitive-closure edges (dashed)")
                        .clicked()
                    {
                        self.depdag.toggle_transitive();
                        if let Some(m) = self.depdag_model.clone() {
                            self.depdag.build(&m);
                        }
                    }
                    if ui
                        .button("⬇ probe registry")
                        .on_hover_text("Fetch each crate's published version from crates.io and light up will-publish / desync / already-published (network)")
                        .clicked()
                    {
                        self.probe_registry();
                    }
                    ui.separator();
                    depdag_legend(ui, &theme);
                });
                ui.separator();
                // Give the interactive pane a bounded height so it coexists with the
                // op-log/gate sections in the scroll column.
                let size = egui::vec2(ui.available_width(), 320.0);
                ui.allocate_ui(size, |ui| {
                    self.depdag.ui(ui);
                });
            });
    }

    fn draw_gate_section(&mut self, ui: &mut egui::Ui) {
        self.draw_depdag_section(ui);
        let theme = self.theme;
        egui::CollapsingHeader::new("🩺 Gate / Doctor")
            .default_open(true)
            .show(ui, |ui| {
                if self.gate_repos.is_empty() {
                    ui.label(
                        RichText::new(
                            "gate/doctor is local-only — run `nornir release doctor` / `nornir release gate edition <repo>` via the CLI",
                        )
                        .color(theme.text_dim),
                    );
                    return;
                }
                if let Some(err) = &self.gate_error {
                    ui.colored_label(RED, format!("gate/doctor failed: {err}"));
                    return;
                }
                let Some(m) = &self.gate else {
                    ui.label(RichText::new("(computing gate model…)").color(theme.text_dim));
                    return;
                };

                // ── dirty trees ──────────────────────────────────────────────
                let dirty: Vec<&str> =
                    m.doctor.dirty.iter().filter(|d| d.dirty).map(|d| d.repo.as_str()).collect();
                ui.horizontal_wrapped(|ui| {
                    ui.strong("Working trees:");
                    if dirty.is_empty() {
                        ui.colored_label(GREEN, "✅ all clean");
                    } else {
                        ui.colored_label(AMBER, format!("🟡 dirty: {}", dirty.join(", ")));
                    }
                });

                // ── external-dep skew (with ⛔ transitive-pin rows) ───────────
                ui.add_space(2.0);
                ui.strong(format!("External dependency skew ({}):", m.doctor.skew.len()));
                if m.doctor.skew.is_empty() {
                    ui.colored_label(GREEN, "  ✅ no divergence");
                } else {
                    for c in &m.doctor.skew {
                        ui.label(
                            RichText::new(format!("  {} (target {})", c.crate_name, c.target))
                                .color(theme.text),
                        );
                        for e in &c.entries {
                            use doctor::SkewStatus;
                            let (glyph, col) = match e.status {
                                SkewStatus::Ok => ("✓", GREEN),
                                SkewStatus::Behind if e.held_by_transitive_pin => ("⛔", RED),
                                SkewStatus::Behind => ("·", theme.text_dim),
                                SkewStatus::Forbidden => ("⚠", AMBER),
                            };
                            let note = if e.held_by_transitive_pin {
                                format!("  (held: lock already resolves {}; a transitive dep pins {})", c.target, e.version)
                            } else {
                                String::new()
                            };
                            ui.colored_label(
                                col,
                                format!("      {glyph} {} {}{}", e.repo, e.version, note),
                            );
                        }
                    }
                }

                // ── cycle-break advice ───────────────────────────────────────
                ui.add_space(2.0);
                if m.doctor.cycle_advice.is_empty() {
                    ui.horizontal(|ui| {
                        ui.strong("Cycle-break advice:");
                        ui.colored_label(GREEN, "✅ clean DAG (no cycle)");
                    });
                } else {
                    ui.strong(format!("Cycle-break advice ({}):", m.doctor.cycle_advice.len()));
                    for a in &m.doctor.cycle_advice {
                        ui.colored_label(
                            AMBER,
                            format!("  ⟲ {} — 💡 {}", a.members.join(" ⇄ "), a.rationale),
                        );
                    }
                }

                // ── edition-2024 gate ────────────────────────────────────────
                ui.add_space(2.0);
                ui.horizontal_wrapped(|ui| {
                    ui.strong("Edition 2024 gate:");
                    if m.edition.is_clean() {
                        ui.colored_label(GREEN, "✅ clean");
                    } else {
                        ui.colored_label(
                            RED,
                            format!(
                                "✗ {} static finding(s){}",
                                m.edition.static_findings.len(),
                                if m.edition.lint_pass_ran {
                                    format!(" + {} lint(s)", m.edition.lints.len())
                                } else {
                                    String::new()
                                },
                            ),
                        );
                    }
                });
                for f in &m.edition.static_findings {
                    ui.colored_label(theme.text_dim, format!("    · {} — {}", f.package, f.issue));
                }
                if m.edition.lint_pass_ran {
                    for l in &m.edition.lints {
                        ui.colored_label(AMBER, format!("    ⚠ {l}"));
                    }
                }
                // The HEAVY opt-in: run the dynamic `cargo check` 2024-compat lint
                // pass (multi-second) — off by default per the perf caveat.
                if !self.edition_lint_requested {
                    if ui
                        .button("▶ run edition lint pass (cargo check)")
                        .on_hover_text("HEAVY: shells `cargo check` with --force-warn rust-2024-compatibility; multi-second")
                        .clicked()
                    {
                        self.edition_lint_requested = true;
                        self.gate = None; // force recompute with the dynamic pass
                        self.loaded = false;
                    }
                } else {
                    ui.label(RichText::new("(dynamic lint pass ran)").color(theme.text_dim));
                }
            });
    }

    /// Test/introspection hook: the release-op rows + run + per-component status
    /// the tab is rendering, as a JSON value folded into the app's `state_json`.
    pub fn state_json(&self) -> serde_json::Value {
        let current = self.current();
        let rows: Vec<serde_json::Value> = current
            .map(|run| {
                run.rows
                    .iter()
                    .map(|r| {
                        serde_json::json!({
                            "component": r.component,
                            "op": r.op,
                            "phase": r.phase,
                            "status": r.status,
                            "line": op_log_line(r),
                        })
                    })
                    .collect()
            })
            .unwrap_or_default();
        let comp_status = current
            .map(|run| component_status(&run.rows))
            .unwrap_or_default();
        serde_json::json!({
            "source": match &self.src {
                Src::Local(p) => format!("local {}", p.display()),
                Src::Remote { endpoint, workspace, .. } => {
                    format!("remote {endpoint} ws={workspace} (Viz.ReleaseEvents)")
                }
            },
            "error": self.error,
            "runs": self.runs.len(),
            "selected_run": current.map(|r| r.run_id.clone()),
            "rows": rows,
            "component_status": comp_status,
            "palette": self.theme.name,
            // The Gate / Doctor model this pane renders (track C/D): skew, the ⛔
            // transitive-pin held-back set, cycle-break advice, and the edition gate.
            "gate": self.gate_json(),
            // The crate publish-DAG (facett dep-graph): nodes = crates, edges =
            // depends-on, waves = parallel publish order, per-crate registry state.
            "depdag": self.depdag.state_json(),
            // The HEAVY operation button this pane exposes (this feature): the
            // release-grade gate → Ops.RunRelease. The matrix walks `ops` to prove
            // the button is wired; `run_result` reports the last gate's outcome.
            "ops": {
                "buttons": [ { "id": "run_release_gate", "rpc": "Ops.RunRelease", "heavy": true, "confirm": true } ],
                "armed": self.run_armed,
            },
            "run_result": match &self.run_result {
                None => serde_json::json!({ "ran": false }),
                Some(Ok(r)) => serde_json::json!({
                    "ran": true, "ok": r.ok, "summary": r.summary, "run_id": r.run_id,
                    "targets": r.targets.iter().map(|(n, s, m)| serde_json::json!({ "name": n, "status": s, "message": m })).collect::<Vec<_>>(),
                }),
                Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
            },
        })
    }
}

// ─── grouping + status reduction ─────────────────────────────────────────────

/// Group rows into runs (newest first; rows within a run kept in `seq` order —
/// `query_release_events` already sorts by `(run_id, seq)`).
fn group_runs(rows: Vec<ReleaseEventRow>) -> Vec<RunGroup> {
    let mut by_run: BTreeMap<String, Vec<ReleaseEventRow>> = BTreeMap::new();
    for r in rows {
        by_run.entry(r.run_id.clone()).or_default().push(r);
    }
    let mut runs: Vec<RunGroup> = by_run
        .into_iter()
        .map(|(run_id, mut rows)| {
            rows.sort_by_key(|r| r.seq);
            let latest_ts = rows.iter().map(|r| r.ts_micros).max().unwrap_or(0);
            RunGroup {
                run_id,
                rows,
                latest_ts,
            }
        })
        .collect();
    // Newest run on top.
    runs.sort_by(|a, b| b.latest_ts.cmp(&a.latest_ts));
    runs
}

/// Latest status per component within a run, walking rows in `seq` order so the
/// final state wins (a `running` start followed by an `ok` end → `ok`).
fn component_status(rows: &[ReleaseEventRow]) -> BTreeMap<String, String> {
    let mut out: BTreeMap<String, String> = BTreeMap::new();
    for r in rows {
        // Skip the `run/*` envelope rows (no real component lane).
        if r.op == "run" {
            continue;
        }
        out.insert(r.component.clone(), r.status.clone());
    }
    out
}

/// Components in deps-first order: a simple Kahn toposort over the run's
/// `depends_on` edges; falls back to first-seen order on a cycle / missing data.
fn topo_order(run: &RunGroup) -> Vec<String> {
    use std::collections::{BTreeSet, VecDeque};
    // Components that are real ops (not the run envelope).
    let mut comps: Vec<String> = Vec::new();
    let mut seen: BTreeSet<String> = BTreeSet::new();
    for r in &run.rows {
        if r.op == "run" {
            continue;
        }
        if seen.insert(r.component.clone()) {
            comps.push(r.component.clone());
        }
    }
    let set: BTreeSet<&str> = comps.iter().map(String::as_str).collect();
    let mut indeg: BTreeMap<&str, usize> = comps.iter().map(|c| (c.as_str(), 0)).collect();
    let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
    for c in &comps {
        if let Some(deps) = depends_on_of(run, c) {
            for d in deps {
                if set.contains(d.as_str()) {
                    // edge c → d means c consumes d; we want d first.
                    adj.entry(d.as_str()).or_default().push(c.as_str());
                    *indeg.entry(c.as_str()).or_insert(0) += 1;
                }
            }
        }
    }
    let mut q: VecDeque<&str> = comps
        .iter()
        .map(String::as_str)
        .filter(|c| indeg[c] == 0)
        .collect();
    let mut out: Vec<String> = Vec::new();
    while let Some(c) = q.pop_front() {
        out.push(c.to_string());
        if let Some(children) = adj.get(c) {
            for &ch in children {
                let Some(d) = indeg.get_mut(ch) else { continue };
                *d = d.saturating_sub(1);
                if *d == 0 {
                    q.push_back(ch);
                }
            }
        }
    }
    if out.len() == comps.len() { out } else { comps }
}

/// The `depends_on` list recorded for a component in this run (the first non-null
/// occurrence — every per-repo row carries the same list).
fn depends_on_of<'a>(run: &'a RunGroup, comp: &str) -> Option<&'a Vec<String>> {
    run.rows
        .iter()
        .find(|r| r.component == comp && r.depends_on.is_some())
        .and_then(|r| r.depends_on.as_ref())
}

enum RunOutcome {
    Running,
    Ok,
    Fail,
}

/// The run's overall outcome: any `fail` → Fail; else any non-`end` op still open
/// → Running; else Ok.
fn run_outcome(rows: &[ReleaseEventRow]) -> RunOutcome {
    if rows.iter().any(|r| r.status == status::FAIL) {
        return RunOutcome::Fail;
    }
    // A run that emitted a `run/end ok` is done.
    let ended_ok = rows
        .iter()
        .any(|r| r.op == "run" && r.phase == "end" && r.status == status::OK);
    if ended_ok {
        RunOutcome::Ok
    } else if rows.iter().any(|r| r.status == status::RUNNING) {
        RunOutcome::Running
    } else {
        RunOutcome::Ok
    }
}

// ─── rendering helpers ───────────────────────────────────────────────────────

/// The op-log line for a row: `[component] op phase status (elapsed) — detail`.
fn op_log_line(r: &ReleaseEventRow) -> String {
    let elapsed = r
        .elapsed_ms
        .map(|ms| format!(" ({:.1}s)", ms as f32 / 1000.0))
        .unwrap_or_default();
    let detail = if r.detail.is_empty() {
        String::new()
    } else {
        format!(" — {}", r.detail)
    };
    format!(
        "[{}] {} {} {}{}{}",
        r.component, r.op, r.phase, r.status, elapsed, detail
    )
}

/// One coloured op-log row (the component-prefixed worklog line).
fn op_log_row(ui: &mut egui::Ui, theme: &Theme, r: &ReleaseEventRow) {
    let (glyph, col) = match r.status.as_str() {
        status::OK => ("✓", GREEN),
        status::FAIL => ("✗", RED),
        status::WARN => ("⚠", AMBER),
        status::RUNNING => ("…", AMBER),
        _ => ("·", theme.text_dim),
    };
    ui.horizontal(|ui| {
        ui.colored_label(col, glyph);
        // `[component]` prefix in a distinct tint so lanes are scannable.
        ui.label(
            RichText::new(format!("[{}]", r.component))
                .monospace()
                .size(12.0)
                .color(theme.text_dim),
        );
        ui.colored_label(col, op_log_line_suffix(r));
    });
}

/// The part of the op-log line after the `[component]` prefix (so the row can
/// tint the prefix separately).
fn op_log_line_suffix(r: &ReleaseEventRow) -> String {
    let elapsed = r
        .elapsed_ms
        .map(|ms| format!(" ({:.1}s)", ms as f32 / 1000.0))
        .unwrap_or_default();
    let detail = if r.detail.is_empty() {
        String::new()
    } else {
        format!(" — {}", r.detail)
    };
    format!("{} {} {}{}{}", r.op, r.phase, r.status, elapsed, detail)
}

/// Node outline / status-text colour for a component's latest status.
fn lit_color(theme: &Theme, st: &str) -> Color32 {
    match st {
        status::OK => GREEN,
        status::FAIL => RED,
        status::WARN => AMBER,
        status::RUNNING => AMBER,
        _ => theme.node_stroke, // idle / dim
    }
}

fn short_run(id: &str) -> String {
    if id.chars().count() > 12 {
        let head: String = id.chars().take(12).collect();
        format!("{head}…")
    } else {
        id.to_string()
    }
}

/// Status legend for the graph + op-log.
fn legend(ui: &mut egui::Ui, theme: &Theme) {
    for (st, label) in [
        (status::RUNNING, "running"),
        (status::OK, "ok"),
        (status::FAIL, "fail"),
        ("idle", "idle"),
    ] {
        ui.colored_label(lit_color(theme, st), "●");
        ui.weak(label);
        ui.add_space(4.0);
    }
}

/// Registry-state legend for the crate publish-DAG (matches `release_depdag`'s
/// node fills).
fn depdag_legend(ui: &mut egui::Ui, _theme: &Theme) {
    for (col, label) in [
        (Color32::from_rgb(90, 200, 140), "will-publish"),
        (Color32::from_rgb(120, 130, 140), "published"),
        (Color32::from_rgb(230, 90, 90), "desync"),
        (Color32::from_rgb(90, 150, 230), "unpublished"),
        (Color32::from_rgb(110, 110, 120), "unknown"),
    ] {
        ui.colored_label(col, "●");
        ui.weak(label);
        ui.add_space(4.0);
    }
}

/// The reader's `Viz.Knowledge` answer → the pane's DEEP crate → crate edges.
fn deep_edges_from_answer(v: &serde_json::Value) -> Result<Vec<nornir_release_core::depdag::CrateDepEdge>, String> {
    let rows = v["crate_edges"].as_array().ok_or_else(|| "the reader's answer carries no `crate_edges` (an older server)".to_string())?;
    Ok(rows
        .iter()
        .map(|e| nornir_release_core::depdag::CrateDepEdge {
            from: e["from"].as_str().unwrap_or("").to_string(),
            to: e["to"].as_str().unwrap_or("").to_string(),
            via: e["via"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(),
        })
        .collect())
}

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

    fn row(
        run: &str,
        seq: i64,
        comp: &str,
        op: &str,
        phase: &str,
        st: &str,
        deps: Option<Vec<&str>>,
        elapsed: Option<i64>,
    ) -> ReleaseEventRow {
        ReleaseEventRow {
            run_id: run.into(),
            seq,
            ts_micros: seq * 10,
            component: comp.into(),
            repo: comp.into(),
            op: op.into(),
            phase: phase.into(),
            status: st.into(),
            detail: String::new(),
            depends_on: deps.map(|v| v.into_iter().map(String::from).collect()),
            elapsed_ms: elapsed,
        }
    }

    #[test]
    fn group_runs_newest_first_and_seq_ordered() {
        let rows = vec![
            row(
                "runA",
                1,
                "znippy",
                "test",
                "end",
                status::OK,
                None,
                Some(1200),
            ),
            row(
                "runA",
                0,
                "znippy",
                "test",
                "start",
                status::RUNNING,
                None,
                None,
            ),
            row(
                "runB",
                0,
                "korp",
                "test",
                "start",
                status::RUNNING,
                Some(vec![]),
                None,
            ),
        ];
        // runB has the highest ts (seq 0 * 10 = 0) vs runA (max seq 1 → ts 10),
        // so runA is newest. Verify ordering + per-run seq order.
        let runs = group_runs(rows);
        assert_eq!(runs.len(), 2);
        assert_eq!(runs[0].run_id, "runA", "newest (highest ts) run on top");
        assert_eq!(
            runs[0].rows.iter().map(|r| r.seq).collect::<Vec<_>>(),
            vec![0, 1]
        );
    }

    #[test]
    fn component_status_takes_latest() {
        let rows = vec![
            row(
                "r",
                0,
                "znippy",
                "test",
                "start",
                status::RUNNING,
                None,
                None,
            ),
            row("r", 1, "znippy", "test", "end", status::OK, None, Some(5)),
            row(
                "r",
                2,
                "holger",
                "gate",
                "start",
                status::RUNNING,
                Some(vec!["znippy"]),
                None,
            ),
            row("r", 3, "r", "run", "end", status::OK, None, None), // envelope skipped
        ];
        let st = component_status(&rows);
        assert_eq!(st.get("znippy").map(String::as_str), Some(status::OK));
        assert_eq!(st.get("holger").map(String::as_str), Some(status::RUNNING));
        assert!(
            !st.contains_key("r"),
            "run-envelope component is not a node"
        );
    }

    #[test]
    fn topo_order_is_deps_first() {
        let run = RunGroup {
            run_id: "r".into(),
            latest_ts: 0,
            rows: vec![
                row(
                    "r",
                    0,
                    "holger",
                    "gate",
                    "start",
                    status::RUNNING,
                    Some(vec!["znippy"]),
                    None,
                ),
                row(
                    "r",
                    1,
                    "znippy",
                    "test",
                    "start",
                    status::RUNNING,
                    Some(vec![]),
                    None,
                ),
            ],
        };
        // holger depends on znippy → znippy must come first.
        let order = topo_order(&run);
        assert_eq!(order, vec!["znippy".to_string(), "holger".to_string()]);
    }

    #[test]
    fn op_log_line_is_component_prefixed() {
        let r = row(
            "r",
            0,
            "znippy",
            "test",
            "end",
            status::OK,
            None,
            Some(1200),
        );
        assert_eq!(op_log_line(&r), "[znippy] test end ok (1.2s)");
    }

    /// Robot scenario (headless, asserts on `state_json`): a minimal 2-repo
    /// workspace (`app` → `base` via a path dep) is scanned into the crate
    /// publish-DAG; assert the `"depdag"` block reports the nodes/edges, the
    /// publish waves (base before app), and — with a fixture registry probe — the
    /// per-crate local-vs-published state.
    #[test]
    fn release_depdag_scans_a_workspace_and_reports_waves() {
        use std::fs;
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        fs::create_dir_all(root.join("base")).unwrap();
        fs::write(
            root.join("base/Cargo.toml"),
            "[package]\nname = \"base\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
        )
        .unwrap();
        fs::create_dir_all(root.join("app")).unwrap();
        fs::write(
            root.join("app/Cargo.toml"),
            "[package]\nname = \"app\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\nbase = { path = \"../base\", version = \"0.1.0\" }\n",
        )
        .unwrap();

        let mut tab = ReleaseTabState::local(root.to_path_buf());
        tab.set_gate_inputs(root, &["base".into(), "app".into()], DepPolicy::default());
        let n = tab.build_depdag_for_test();
        assert_eq!(n, 2, "app + base");

        let j = tab.state_json();
        let dd = &j["depdag"];
        assert_eq!(dd["mode"], "thin");
        assert_eq!(dd["layout"]["node_count"], 2);
        assert_eq!(dd["layout"]["direct_edges"], 1, "app → base");
        // waves: base publishes first (depends on nothing), then app.
        assert_eq!(dd["waves"][0][0], "base");
        assert_eq!(dd["publishable"], true);

        // Registry annotation: inject a fixture probe (offline) and reassemble.
        tab.depdag_published = [
            ("app".to_string(), Some("0.9.0".to_string())), // local ahead → will publish
            ("base".to_string(), None),                     // unpublished → first publish
        ]
        .into_iter()
        .collect();
        tab.build_depdag_for_test();
        let j = tab.state_json();
        let nodes = j["depdag"]["dag"]["nodes"].as_array().unwrap();
        let app = nodes.iter().find(|x| x["name"] == "app").unwrap();
        assert_eq!(app["state"], "will_publish");
        let base = nodes.iter().find(|x| x["name"] == "base").unwrap();
        assert_eq!(base["state"], "unpublished");

        // THIN→DEEP toggle: inject a symbol-derived edge set (no warehouse) and flip.
        // The `mode` flips to "deep" and the laid-out graph now shows the symbol edge.
        tab.toggle_deep_for_test(vec![nornir_release_core::depdag::CrateDepEdge {
            from: "app".into(),
            to: "base".into(),
            via: vec!["open".into()],
        }]);
        let j = tab.state_json();
        let dd = &j["depdag"];
        assert_eq!(dd["mode"], "deep");
        assert_eq!(dd["deep_edge_count"], 1);
        assert_eq!(
            dd["layout"]["node_count"], 2,
            "same crate nodes in DEEP mode"
        );
        assert_eq!(
            dd["layout"]["direct_edges"], 1,
            "the one symbol-derived edge"
        );
    }

    #[test]
    fn run_outcome_fail_wins() {
        let rows = vec![
            row("r", 0, "znippy", "test", "end", status::OK, None, Some(5)),
            row("r", 1, "holger", "test", "end", status::FAIL, None, Some(3)),
        ];
        assert!(matches!(run_outcome(&rows), RunOutcome::Fail));
    }

    /// The DEEP edges are the code room's crate → crate edges; a workspace
    /// nothing was consumed into leaves them UNAVAILABLE with the reason.
    #[cfg(feature = "code-room")]
    #[test]
    fn deep_edges_come_from_the_code_room() {
        use crate::code_room::reader::fixture;
        let mut st = ReleaseTabState::local(fixture::shop().builds.clone());
        st.compute_deep_edges();
        let js = st.depdag.state_json();
        assert_eq!((js["deep_state"].as_str(), js["deep_edge_count"].as_u64()), (Some("available"), Some(1)), "{js}");
        let answer = crate::viz::remote::local_surface(&fixture::shop().builds, "ws", "Viz.Knowledge", &serde_json::Value::Null).unwrap();
        let edges = deep_edges_from_answer(&answer).unwrap();
        assert_eq!(edges.iter().map(|e| (e.from.as_str(), e.to.as_str(), e.via.clone())).collect::<Vec<_>>(), vec![("web", "shop", vec!["checkout".to_string()])]);

        let mut st = ReleaseTabState::local(fixture::unconsumed().builds.clone());
        st.compute_deep_edges();
        let js = st.depdag.state_json();
        assert_eq!(js["deep_state"], "unavailable");
        assert!(js["deep_note"].as_str().unwrap().contains("[not_consumed]"), "{js}");
        assert_eq!(js["deep_edge_count"], 0);
    }
}