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

nordisk/nornir

src/viz/nornir_tab.rs

//! 🧬 **`nornir` root pane** — the server/root operations that are *not*
//! workspace-scoped data views: workspace **lifecycle** (Add / Kill), **Populate**
//! and **Refresh/Poll-now**, plus the server **status / version**.
//!
//! Every other pane is a *view onto one workspace's data*; this one is where a
//! workspace is born and where it dies. (The user notes it could equally have
//! been called "server" — it holds the root, server-level operations.)
//!
//! # Operation layout (decision: ops live in the pane they belong to)
//! * **Add workspace** — form (name + descriptor path/URL + mode + poll) →
//!   `Workspaces.Register` (eager populate). CLI: `nornir workspace add`.
//! * **Kill workspace** — a confirm-gated de-register → `Workspaces.Remove`.
//!   CLI: `nornir workspace rm`.
//! * **Populate** — clone members now, build async → `Workspaces.Fetch{background}`.
//!   CLI: `nornir workspace populate`.
//! * **Refresh / Poll-now** — force a fetch+rebuild now → `Workspaces.Fetch{force}`.
//!   CLI: `nornir workspace fetch`.
//! * **Server status** — `Health.Ping` (version + repo count + status).
//!
//! All are **remote-only** (they drive a running `nornir-server`); in local mode
//! the pane shows the same one-line hint as the other server-backed panes.
//!
//! Follows the facett discovery contract the test matrix walks: a `*View`-named
//! type with `local()`/`remote()` ctors, a [`state_json`](NornirRootView::state_json)
//! that reports every button + form field + last result (LAW #6 — "see what the
//! user sees" as data), so the headless matrix mechanically discovers the pane and
//! its operations.

use std::path::PathBuf;

use eframe::egui::{self};

use super::action_log::{ActionLog, Kind};
use super::facett_theme::{GREEN, RED, Theme};
use super::ops_tabs::Server;
use super::remote;
use crate::registry::RosterRow;
use crate::warehouse::clone_events::CloneEventRow;
// The job list is the reusable, nornir-agnostic facett component; we only map
// our `jobs::JobRecord` onto its `JobEntry` and let it render.
use facett_jobview::{Facet, JobEntry, JobList, JobStatus};

/// 🧬 The `nornir` root pane state. Remote-only; holds the Add-workspace form,
/// the kill-confirm latch, and the last result of each root operation so the
/// rendered outcome survives between frames and is reported in `state_json`.
pub struct NornirRootView {
    /// Add-workspace form fields.
    new_name: String,
    new_descriptor: String,
    new_mode: String, // "monitored" | "pushed" | "external"
    new_poll: String,
    /// The workspace selected for the (confirm-gated) Kill button.
    kill_target: String,
    /// Two-step confirm latch for Kill (a destructive root op).
    kill_armed: bool,
    /// Whether the Kill also purges the workspace's on-disk `builds/` data (the
    /// `--purge` of `nornir workspace rm`). Off = registry-only (back-compat).
    kill_purge: bool,
    /// Last result of each operation (rendered + reported), `Ok(msg)`/`Err(msg)`.
    last_add: Option<Result<String, String>>,
    /// The name of the workspace registered by the LAST successful Add, latched
    /// for the caller to pick up via [`take_just_registered`](Self::take_just_registered)
    /// so the app can auto-SELECT the freshly-added workspace (the picker switch)
    /// — the user sees it populate live instead of having to choose it manually.
    just_registered: Option<String>,
    last_kill: Option<Result<String, String>>,
    last_populate: Option<Result<String, String>>,
    last_refresh: Option<Result<String, String>>,
    /// Cached `Health.Ping` server identity, fetched on demand.
    server_info: Option<Result<remote::ServerInfo, String>>,
    /// PLAN #6: cached per-member populate status (`clone_events`) for the active
    /// workspace — `Ok(rows)` newest-first, or the load error. Loaded on demand via
    /// `Viz.CloneEvents` (remote) or the local warehouse (local mode).
    clone_events: Option<Result<Vec<CloneEventRow>, String>>,
    /// The workspace the cached `clone_events` belong to (so a workspace switch
    /// invalidates the cache).
    clone_events_ws: String,
    /// PO1: the multi-workspace POPULATE ROSTER — every served workspace × its
    /// rolled-up populate verdict (green/red/stale + failing member + error).
    /// `Ok(rows)` or the load error; `None` until first loaded. Remote reads the
    /// `Workspaces.PopulateStatus` roll-up RPC (ONE round-trip for the whole
    /// roster); local rolls up the active workspace from its warehouse.
    roster: Option<Result<Vec<RosterRow>, String>>,
    /// `true` once the roster auto-loaded (so it doesn't reload every frame).
    roster_loaded: bool,
    /// 🧰 The unified job list — the reusable `facett-jobview` component (no
    /// nornir UI code). Fed from `Viz.Jobs` (remote) or the local `jobs.redb`
    /// (lock-tolerant) on a ~1.5s poll; we only map `jobs::JobRecord → JobEntry`.
    jobs_view: JobList,
    /// Workspace the cached job list belongs to (a switch forces a reload).
    jobs_ws: String,
    /// Last job poll (throttles the ~1.5s refresh — swappable to skade streaming).
    jobs_last_poll: Option<std::time::Instant>,
    /// Last job-load error (shown above the list), or `None`.
    jobs_error: Option<String>,
    /// Parent job ids whose **default fold** has already been decided, so the
    /// auto-collapse rule runs at most once per parent and never fights a user
    /// toggle afterward. A parent is recorded here the first time it is seen
    /// fully done (all descendants terminal-success) and gets folded; running /
    /// failed-bearing parents stay unfolded and are *not* recorded (they may
    /// still complete later, at which point they fold once). See
    /// [`apply_default_job_folds`](Self::apply_default_job_folds).
    jobs_fold_decided: std::collections::HashSet<String>,
    theme: Theme,
}

impl Default for NornirRootView {
    fn default() -> Self {
        Self {
            new_name: String::new(),
            new_descriptor: String::new(),
            new_mode: "monitored".into(),
            new_poll: "60s".into(),
            kill_target: String::new(),
            kill_armed: false,
            kill_purge: false,
            last_add: None,
            just_registered: None,
            last_kill: None,
            last_populate: None,
            last_refresh: None,
            server_info: None,
            clone_events: None,
            clone_events_ws: String::new(),
            roster: None,
            roster_loaded: false,
            jobs_view: JobList::new("Jobs"),
            jobs_ws: String::new(),
            jobs_last_poll: None,
            jobs_error: None,
            jobs_fold_decided: std::collections::HashSet::new(),
            theme: Theme::default(),
        }
    }
}

impl NornirRootView {
    /// Local-warehouse ctor (no server → the pane shows the server-backed hint).
    pub fn local() -> Self {
        Self::default()
    }

    /// Remote (thin-client) ctor — identical state; the live `Server` handle is
    /// passed per-frame to [`draw`](Self::draw), mirroring the other ops panes.
    pub fn remote() -> Self {
        Self::default()
    }

    /// Re-skin with a facett palette (broadcast from the top-bar picker).
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// Take (and clear) the workspace name registered by the last successful Add,
    /// if any. The app calls this after a mutating [`draw`](Self::draw) so it can
    /// auto-SELECT the freshly-added workspace (drive the picker switch), making
    /// the new workspace start populating live in the panes without the user
    /// having to pick it from the top-bar. Returns `None` once consumed.
    #[must_use]
    pub fn take_just_registered(&mut self) -> Option<String> {
        self.just_registered.take()
    }

    /// Render the root pane. `srv` is `None` in local mode (shows the hint).
    /// `workspaces` is the current picker list (the kill-target choices).
    /// Returns `true` when an operation mutated the server's workspace set (Add /
    /// Kill / Populate / Refresh succeeded) so the caller can re-list workspaces +
    /// reload the view immediately, the same contract `WorkspacePanel::draw_controls`
    /// uses for ⟳ Sync now.
    #[must_use]
    pub fn draw(
        &mut self,
        ui: &mut egui::Ui,
        srv: Option<&Server>,
        workspaces: &[String],
        active_ws: &str,
        local_warehouse: Option<&PathBuf>,
        log: &ActionLog,
    ) -> bool {
        let theme = self.theme;
        ui.heading("🧬 nornir — server & workspace operations");

        // ── Workspace ROSTER + populate status (PO1) ─────────────────────────
        // Every served workspace × {members, last-synced, populate verdict, last
        // error}. Remote reads the `Workspaces.PopulateStatus` roll-up; local
        // rolls up the active workspace from its warehouse. The red/green roster.
        self.draw_roster(ui, srv, workspaces, active_ws, local_warehouse, log);

        // ── Populate status (clone_events, PLAN #6) ──────────────────────────
        // Works in BOTH modes: remote reads `Viz.CloneEvents`, local reads the
        // workspace warehouse (lock-tolerant). Shows per-member ok/failed + the
        // error detail + when, so a thin client sees *why* a member didn't
        // populate instead of only missing data.
        self.draw_populate_status(ui, srv, active_ws, local_warehouse, log);

        // ── 🧰 Jobs (the facett-jobview component) — works in BOTH modes, so it
        // renders before the local-mode early return below.
        self.draw_jobs(ui, srv, active_ws, local_warehouse);

        let Some(srv) = srv else {
            ui.add_space(20.0);
            ui.label(
                "The nornir root pane's lifecycle ops are server-backed — launch the viz against \
                 a running nornir-server (NORNIR_SERVER=…) to add/kill/populate workspaces.",
            );
            return false;
        };
        let mut mutated = false;

        // ── Server status (Health.Ping) ──────────────────────────────────────
        ui.separator();
        ui.horizontal(|ui| {
            ui.strong("server:");
            if ui
                .button("⟳ status")
                .on_hover_text("Health.Ping — version + repo count")
                .clicked()
            {
                log.push(Kind::Rpc, "Health.Ping".to_string());
                self.server_info =
                    Some(remote::ping(&srv.endpoint, &srv.token).map_err(|e| format!("{e:#}")));
            }
            match &self.server_info {
                Some(Ok(i)) => {
                    ui.colored_label(
                        GREEN,
                        format!(
                            "{} · nornir {} · {} repo(s)",
                            i.status, i.version, i.repo_count
                        ),
                    );
                }
                Some(Err(e)) => {
                    ui.colored_label(RED, e);
                }
                None => {
                    ui.colored_label(theme.text_dim, format!("{}", srv.endpoint));
                }
            }
        });

        // ── Add workspace (Workspaces.Register, eager populate) ───────────────
        ui.separator();
        ui.strong("➕ add workspace");
        egui::Grid::new("nornir_add_ws")
            .num_columns(2)
            .spacing([8.0, 4.0])
            .show(ui, |ui| {
                ui.label("name:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.new_name)
                        .desired_width(220.0)
                        .hint_text("workspace name"),
                );
                ui.end_row();
                ui.label("descriptor:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.new_descriptor)
                        .desired_width(360.0)
                        .hint_text("git ssh URL, or local path to a checked-out workspace repo"),
                );
                ui.end_row();
                ui.label("mode:");
                egui::ComboBox::from_id_salt("nornir_add_mode")
                    .selected_text(self.new_mode.clone())
                    .show_ui(ui, |ui| {
                        for m in ["monitored", "pushed", "external"] {
                            ui.selectable_value(&mut self.new_mode, m.to_string(), m);
                        }
                    });
                ui.end_row();
                ui.label("poll:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.new_poll)
                        .desired_width(100.0)
                        .hint_text("60s"),
                );
                ui.end_row();
            });
        if ui
            .button("➕ Add workspace")
            .on_hover_text("Workspaces.Register (eager populate) — CLI: nornir workspace add")
            .clicked()
        {
            // Give feedback instead of silently doing nothing on a missing name/descriptor.
            if self.new_name.trim().is_empty() {
                self.last_add = Some(Err("name is required".to_string()));
            } else if self.new_descriptor.trim().is_empty() {
                self.last_add = Some(Err(
                    "descriptor is required — a git ssh URL or a local checked-out repo path"
                        .to_string(),
                ));
            } else {
                log.push(
                    Kind::Rpc,
                    format!("Workspaces.Register name={}", self.new_name),
                );
                let res = remote::register_workspace(
                    &srv.endpoint,
                    &srv.token,
                    self.new_name.trim(),
                    self.new_descriptor.trim(),
                    &self.new_mode,
                    self.new_poll.trim(),
                );
                // LATCH the server's canonical workspace name on success so the app
                // can auto-SELECT it (the picker switch) — the user watches `W`
                // populate live instead of having to pick it from the top-bar.
                if let Ok((name, _, _)) = &res {
                    self.just_registered = Some(name.clone());
                }
                self.last_add = Some(
                    res.map(|(name, mode, members)| {
                        format!("registered `{name}` ({mode}, {members} member(s))")
                    })
                    .map_err(|e| format!("{e:#}")),
                );
                mutated |= matches!(self.last_add, Some(Ok(_)));
            }
        }
        result_line(ui, theme, &self.last_add);

        // ── Kill workspace (Workspaces.Remove, confirm-gated) ─────────────────
        ui.separator();
        ui.strong("🗑 kill workspace");
        if self.kill_target.is_empty() {
            self.kill_target = workspaces.first().cloned().unwrap_or_default();
        }
        ui.horizontal(|ui| {
            egui::ComboBox::from_id_salt("nornir_kill_target")
                .selected_text(if self.kill_target.is_empty() { "—".into() } else { self.kill_target.clone() })
                .show_ui(ui, |ui| {
                    for w in workspaces {
                        ui.selectable_value(&mut self.kill_target, w.clone(), w);
                    }
                });
            ui.checkbox(&mut self.kill_purge, "purge data")
                .on_hover_text("Also delete the workspace's on-disk builds/ data (warehouse + catalog.redb + jobs.redb), not just the registry row. CLI: nornir workspace rm --purge");
            if !self.kill_armed {
                if ui.button("🗑 Kill…").clicked() && !self.kill_target.trim().is_empty() {
                    self.kill_armed = true;
                }
            } else {
                let warn = if self.kill_purge {
                    format!("⚠ remove `{}` AND purge its builds/ data?", self.kill_target)
                } else {
                    format!("⚠ remove `{}`?", self.kill_target)
                };
                ui.colored_label(RED, warn);
                if ui.button("✓ confirm kill").clicked() {
                    let purge = self.kill_purge;
                    log.push(
                        Kind::Rpc,
                        format!("Workspaces.Remove name={} purge={purge}", self.kill_target),
                    );
                    self.last_kill = Some(
                        remote::remove_workspace(&srv.endpoint, &srv.token, self.kill_target.trim(), purge)
                            .map(|()| {
                                if purge {
                                    format!("removed `{}` (purged builds/ data)", self.kill_target)
                                } else {
                                    format!("removed `{}`", self.kill_target)
                                }
                            })
                            .map_err(|e| format!("{e:#}")),
                    );
                    mutated |= matches!(self.last_kill, Some(Ok(_)));
                    self.kill_armed = false;
                }
                if ui.button("✕ cancel").clicked() {
                    self.kill_armed = false;
                }
            }
        });
        result_line(ui, theme, &self.last_kill);

        // ── Populate + Refresh/Poll-now (Workspaces.Fetch) ────────────────────
        ui.separator();
        ui.strong("🔄 populate / refresh");
        ui.horizontal(|ui| {
            let target = if self.kill_target.is_empty() {
                workspaces.first().cloned().unwrap_or_default()
            } else {
                self.kill_target.clone()
            };
            if ui
                .button("⬇ Populate")
                .on_hover_text("Workspaces.Fetch{background} — clone members now, build async (CLI: nornir workspace populate)")
                .clicked()
                && !target.trim().is_empty()
            {
                log.push(Kind::Rpc, format!("Workspaces.Fetch(background) name={target}"));
                self.last_populate = Some(
                    fetch(&srv.endpoint, &srv.token, &target, false, true)
                        .map_err(|e| format!("{e:#}")),
                );
                mutated |= matches!(self.last_populate, Some(Ok(_)));
            }
            if ui
                .button("⟳ Refresh / poll-now")
                .on_hover_text("Workspaces.Fetch{force} — poll + rebuild this workspace now (CLI: nornir workspace fetch)")
                .clicked()
                && !target.trim().is_empty()
            {
                log.push(Kind::Rpc, format!("Workspaces.Fetch(force) name={target}"));
                self.last_refresh = Some(
                    fetch(&srv.endpoint, &srv.token, &target, true, false)
                        .map_err(|e| format!("{e:#}")),
                );
                mutated |= matches!(self.last_refresh, Some(Ok(_)));
            }
        });
        result_line(ui, theme, &self.last_populate);
        result_line(ui, theme, &self.last_refresh);

        mutated
    }

    /// Throttled poll of the job ledger (`Viz.Jobs` remote / `jobs.redb` local)
    /// that PUBLISHES the active-jobs snapshot for the per-repo panes' shared
    /// empty-state helper ([`super::repo_pane::publish_active_jobs`]) — the one
    /// poll that feeds every pane's `classify_empty` / `populate_active_for`, so
    /// they show "⏳ in route for populate…" + live progress without each running
    /// its own RPC.
    ///
    /// Split out of [`draw_jobs`] so the app can drive it EVERY frame regardless
    /// of the active tab: a populate is normally watched from a per-repo pane
    /// (🧠 knowledge / 🏛 arch / 🔗 depgraph), NOT the 🧬 nornir tab where
    /// `draw_jobs` renders — so when the poll lived only inside `draw_jobs` the
    /// snapshot went stale the moment the user left this tab, and the panes never
    /// showed live populate progress (they sat on "not scanned yet"). Shares the
    /// `jobs_last_poll` / `jobs_ws` throttle with `draw_jobs`, so on the nornir tab
    /// both callers still issue ONE `Viz.Jobs` RPC per ~1.5s cycle.
    ///
    /// Returns `true` while a populate/clone/scan job is active for `active_ws`
    /// (so the caller can keep the frame repainting for live progress).
    pub fn poll_active_jobs(
        &mut self,
        srv: Option<&Server>,
        active_ws: &str,
        local_warehouse: Option<&PathBuf>,
    ) -> bool {
        use std::time::{Duration, Instant};
        if active_ws.is_empty() {
            return false;
        }
        let due = self.jobs_ws != active_ws
            || self
                .jobs_last_poll
                .is_none_or(|t| t.elapsed() >= Duration::from_millis(1500));
        if due {
            self.jobs_ws = active_ws.to_string();
            self.jobs_last_poll = Some(Instant::now());
            match load_jobs(srv, active_ws, local_warehouse) {
                Ok(recs) => {
                    self.jobs_error = None;
                    super::repo_pane::publish_active_jobs(&recs);
                    self.jobs_view
                        .set_jobs(recs.iter().map(job_entry).collect());
                    // Apply the default-open rule each refresh: collapse parents
                    // that have fully finished (tidy), keep running / failing work
                    // expanded. Idempotent + user-toggle-preserving.
                    self.apply_default_job_folds(&recs);
                }
                // SURFACE the failure instead of swallowing it: keep the error so
                // `draw_jobs` paints "jobs unavailable: …" (and a stale snapshot is
                // not silently treated as "no jobs"). A populate that can't be polled
                // is no longer an invisible no-op.
                Err(e) => self.jobs_error = Some(e),
            }
        }
        super::repo_pane::populate_active_for(active_ws, "")
    }

    /// **Default-open rule for the Jobs tree.** The Jobs panel is a collapsible
    /// tree (a populate parent → its clone / knowledge-scan / security / scip /
    /// index / snapshot children, recursively). facett-jobview renders every
    /// parent *unfolded* by default; this layers nornir's policy on top:
    ///
    /// * keep a parent EXPANDED while it is `running`/`queued` or has any
    ///   running/queued/**failed** descendant, so in-progress and erroring work
    ///   is visible at a glance;
    /// * COLLAPSE a parent once its whole subtree has finished cleanly (every
    ///   descendant terminal-success), to keep finished work tidy.
    ///
    /// It is applied each refresh but is **idempotent per parent**: a parent is
    /// auto-folded at most once (recorded in [`jobs_fold_decided`](Self::jobs_fold_decided)),
    /// so once the user expands a collapsed-done parent it stays expanded, and a
    /// parent the user is watching is never yanked shut mid-run — it only folds
    /// the first frame its subtree is fully done. Drives the public
    /// `facett-jobview` fold API (`is_folded` / `toggle_fold`), so the flat
    /// renderer/tree is reused, not replaced.
    fn apply_default_job_folds(&mut self, recs: &[crate::jobs::JobRecord]) {
        use std::collections::HashMap;
        // parent_id → its direct children (by index into `recs`).
        let mut children: HashMap<&str, Vec<usize>> = HashMap::new();
        for (i, r) in recs.iter().enumerate() {
            if let Some(p) = r.parent_id.as_deref() {
                children.entry(p).or_default().push(i);
            }
        }
        // Does `id`'s subtree (the node itself or any descendant) still need
        // attention — i.e. is anything in it non-`done`? `failed`/`running`/
        // `queued` all keep the parent open. Bounded by a visited guard so a
        // malformed cycle can't spin.
        fn subtree_needs_attention(
            id: &str,
            recs: &[crate::jobs::JobRecord],
            children: &HashMap<&str, Vec<usize>>,
            by_id: &HashMap<&str, usize>,
            seen: &mut std::collections::HashSet<String>,
        ) -> bool {
            if !seen.insert(id.to_string()) {
                return false;
            }
            if let Some(&idx) = by_id.get(id) {
                if recs[idx].status != crate::jobs::status::DONE {
                    return true;
                }
            }
            if let Some(kids) = children.get(id) {
                for &ci in kids {
                    let cid = recs[ci].job_id.as_str();
                    if subtree_needs_attention(cid, recs, children, by_id, seen) {
                        return true;
                    }
                }
            }
            false
        }
        let by_id: HashMap<&str, usize> = recs
            .iter()
            .enumerate()
            .map(|(i, r)| (r.job_id.as_str(), i))
            .collect();
        // Only parents (rows that actually have children) get a fold decision.
        let parent_ids: Vec<String> = children.keys().map(|s| s.to_string()).collect();
        for pid in parent_ids {
            if self.jobs_fold_decided.contains(&pid) {
                continue; // decided once already — respect the prior/user state.
            }
            let mut seen = std::collections::HashSet::new();
            if subtree_needs_attention(&pid, recs, &children, &by_id, &mut seen) {
                continue; // still running / failed somewhere → keep it expanded.
            }
            // Fully done: collapse it once, then record the decision so the user
            // can re-open it permanently.
            if !self.jobs_view.is_folded(&pid) {
                // FC-2: mutation goes through the Msg surface (toggle_fold is now private).
                let _ = self
                    .jobs_view
                    .update(facett_jobview::Msg::ToggleFold(pid.clone()));
            }
            self.jobs_fold_decided.insert(pid);
        }
    }

    /// PLAN #6: render the per-member **populate status** for `active_ws` — the
    /// `clone_events` outcomes (ok / failed + error detail + when). Loaded on
    /// demand (a ⟳ button), and auto-loaded once when the active workspace
    /// changes, so a thin client sees *why* a member didn't populate.
    /// Poll + render the job list (the `facett-jobview` component). Polls
    /// `Viz.Jobs` (remote) or the local `jobs.redb` (lock-tolerant) at most every
    /// ~1.5s — the `bench_live` cadence, swappable to skade streaming-on-commit.
    fn draw_jobs(
        &mut self,
        ui: &mut egui::Ui,
        srv: Option<&Server>,
        active_ws: &str,
        local_warehouse: Option<&PathBuf>,
    ) {
        use std::time::Duration;
        // S3c — STABLE id_salt for the `jobs` SUB-PANEL (registry id
        // `Nornir.jobs`): it hides inside the 🧬 tab with no `Tab::ALL` variant, so
        // namespace its atoms so the walk addresses them uniquely. Wraps BOTH the
        // empty-workspace guard and the populated path.
        ui.push_id("Nornir.jobs", |ui| {
            // EMPTY-WORKSPACE GUARD: an empty registry has no workspace, so `Viz.Jobs`
            // with an empty workspace returns `workspace `` is not served` (a RED error
            // atom). Render a neutral onboarding line and skip the poll/RPC. Mirrors the
            // populate-status guard above + `app.rs`'s `workspace_name.is_empty()`.
            if active_ws.is_empty() {
                ui.separator();
                egui::CollapsingHeader::new("🧰 Jobs")
                    .default_open(true)
                    .show(ui, |ui| {
                        ui.colored_label(self.theme.text_dim, "no workspace selected");
                    });
                return;
            }
            // Throttled poll + PUBLISH of the active-jobs snapshot (shared with the
            // app-level drive in `app.rs` so the per-repo panes see live progress even
            // when this tab isn't on screen — one `Viz.Jobs` RPC per cycle either way).
            self.poll_active_jobs(srv, active_ws, local_warehouse);
            ui.separator();
            egui::CollapsingHeader::new("🧰 Jobs")
                .default_open(true)
                .show(ui, |ui| {
                    if let Some(e) = &self.jobs_error {
                        ui.colored_label(RED, format!("jobs unavailable: {e}"));
                    }
                    // Drive the facett component through its canonical Facet contract.
                    Facet::ui(&mut self.jobs_view, ui);
                });
            // Keep the ~1.5s poll cadence ticking even when idle (no input events).
            ui.ctx().request_repaint_after(Duration::from_millis(1500));
        }); // close the S3c `Nornir.jobs` push_id scope
    }

    /// Number of currently-running jobs — drives the app's "ask at close" guard.
    pub fn running_jobs(&self) -> usize {
        self.jobs_view.count(JobStatus::Running)
    }

    /// PO1: render the multi-workspace POPULATE ROSTER — a table of every served
    /// workspace × {members, last-synced, populate verdict, last error}. Loaded on
    /// demand (a ⟳ button) and auto-loaded once. Remote reads the
    /// `Workspaces.PopulateStatus` roll-up (one round-trip); local rolls up the
    /// active workspace from its warehouse. A red verdict names the failing member.
    fn draw_roster(
        &mut self,
        ui: &mut egui::Ui,
        srv: Option<&Server>,
        workspaces: &[String],
        active_ws: &str,
        local_warehouse: Option<&PathBuf>,
        log: &ActionLog,
    ) {
        let theme = self.theme;
        ui.push_id("Nornir.workspaces", |ui| {
        ui.separator();
        ui.horizontal(|ui| {
            ui.strong("📋 workspace roster");
            if ui
                .button("⟳ load")
                .on_hover_text("Workspaces.PopulateStatus — per-workspace populate roll-up (green/red/stale)")
                .clicked()
            {
                log.push(Kind::Rpc, "Workspaces.PopulateStatus".to_string());
                self.roster = Some(load_roster(srv, workspaces, active_ws, local_warehouse));
                self.roster_loaded = true;
            }
        });
        if !self.roster_loaded {
            self.roster = Some(load_roster(srv, workspaces, active_ws, local_warehouse));
            self.roster_loaded = true;
        }
        match &self.roster {
            Some(Ok(rows)) if rows.is_empty() => {
                ui.colored_label(theme.text_dim, "no workspaces registered");
            }
            Some(Ok(rows)) => {
                let red = rows.iter().filter(|r| r.populate == "red").count();
                if red == 0 {
                    ui.colored_label(GREEN, format!("✓ all {} workspace(s) populated ok", rows.len()));
                } else {
                    ui.colored_label(RED, format!("✗ {red} workspace(s) failed to populate"));
                }
                egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
                    egui::Grid::new("nornir_ws_roster").num_columns(5).striped(true).spacing([12.0, 2.0]).show(ui, |ui| {
                        ui.strong("workspace"); ui.strong("members"); ui.strong("last synced"); ui.strong("populate"); ui.strong("last error");
                        ui.end_row();
                        for r in rows {
                            ui.label(&r.name);
                            ui.label(r.members.to_string());
                            ui.label(&r.last_synced);
                            match r.populate.as_str() {
                                "green" => { ui.colored_label(GREEN, "✓ ok"); }
                                "red" => { ui.colored_label(RED, "✗ failed"); }
                                _ => { ui.colored_label(theme.text_dim, "· stale"); }
                            }
                            if r.failing_member.is_empty() {
                                ui.label("");
                            } else {
                                ui.colored_label(RED, format!("{}: {}", r.failing_member, r.last_error));
                            }
                            ui.end_row();
                        }
                    });
                });
            }
            Some(Err(e)) => { ui.colored_label(RED, format!("✗ {e}")); }
            None => {}
        }
        });
    }

    fn draw_populate_status(
        &mut self,
        ui: &mut egui::Ui,
        srv: Option<&Server>,
        active_ws: &str,
        local_warehouse: Option<&PathBuf>,
        log: &ActionLog,
    ) {
        let theme = self.theme;
        // S3c — STABLE id_salt for the `populate_status` SUB-PANEL (registry id
        // `Nornir.populate_status`): hides inside the 🧬 tab with no `Tab::ALL`
        // variant, so namespace its atoms (incl. the `nornir_clone_events` Grid) so
        // the walk addresses them uniquely. Wraps the guard + the populated path.
        ui.push_id("Nornir.populate_status", |ui| {
        ui.separator();
        // EMPTY-WORKSPACE GUARD (the atom-walk class): on a fresh server with an
        // empty registry there is NO workspace, so calling `Viz.CloneEvents` with
        // an empty workspace makes the server answer `workspace `` is not served`
        // — a NotFound that would render as a RED error atom. The empty registry is
        // a legitimate onboarding state, not an error: show a neutral line and skip
        // the RPC entirely (mirrors `app.rs`'s `workspace_name.is_empty()` guard).
        if active_ws.is_empty() {
            ui.horizontal(|ui| {
                ui.strong("🩺 populate status");
                ui.colored_label(theme.text_dim, "no workspace selected");
            });
            return;
        }
        ui.horizontal(|ui| {
            ui.strong("🩺 populate status");
            ui.colored_label(theme.text_dim, active_ws);
            if ui
                .button("⟳ load")
                .on_hover_text("clone_events — per-member populate outcomes (Viz.CloneEvents / local warehouse)")
                .clicked()
            {
                log.push(Kind::Rpc, format!("Viz.CloneEvents workspace={active_ws}"));
                self.clone_events = Some(load_clone_events(srv, active_ws, local_warehouse));
                self.clone_events_ws = active_ws.to_string();
            }
        });
        // Auto-load once on a workspace switch (or first paint) so the pane shows
        // status without a manual click — matches the other data panes.
        if self.clone_events.is_none() || self.clone_events_ws != active_ws {
            self.clone_events = Some(load_clone_events(srv, active_ws, local_warehouse));
            self.clone_events_ws = active_ws.to_string();
        }
        match &self.clone_events {
            Some(Ok(rows)) if rows.is_empty() => {
                ui.colored_label(theme.text_dim, "no clone/populate events recorded yet");
            }
            Some(Ok(rows)) => {
                let failed = rows.iter().filter(|r| r.status == "error").count();
                if failed == 0 {
                    ui.colored_label(GREEN, format!("✓ all {} member event(s) ok", rows.len()));
                } else {
                    ui.colored_label(RED, format!("✗ {failed} member(s) failed to populate"));
                }
                egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
                    egui::Grid::new("nornir_clone_events").num_columns(4).striped(true).spacing([12.0, 2.0]).show(ui, |ui| {
                        ui.strong("member"); ui.strong("op"); ui.strong("status"); ui.strong("detail");
                        ui.end_row();
                        for r in rows.iter().take(50) {
                            ui.label(&r.member);
                            ui.label(&r.op);
                            if r.status == "error" {
                                ui.colored_label(RED, "✗ error");
                            } else {
                                ui.colored_label(GREEN, "✓ ok");
                            }
                            ui.label(&r.detail);
                            ui.end_row();
                        }
                    });
                });
            }
            Some(Err(e)) => { ui.colored_label(RED, format!("✗ {e}")); }
            None => {}
        }
        }); // close the S3c `Nornir.populate_status` push_id scope
    }

    /// The `nornir` pane's slice of `state_json` (LAW #6): every button this pane
    /// hosts (so the matrix can mechanically discover the new operation surface),
    /// the Add-workspace form's current field values, the kill-confirm state, and
    /// the last result of each root operation. No "TODO"/"not wired" sentinels.
    pub fn state_json(&self) -> serde_json::Value {
        let res = |r: &Option<Result<String, String>>| match r {
            None => serde_json::json!({ "ran": false }),
            Some(Ok(m)) => serde_json::json!({ "ran": true, "ok": true, "message": m }),
            Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
        };
        // The Jobs panel's TREE shape, surfaced at the nornir level so a robot test
        // can assert the nesting + the default-open decision directly: `roots` =
        // top-level rows, `parents` = rows with children, `children` = sub-jobs,
        // `folded` = parents currently collapsed (the auto-collapse rule folds
        // fully-done parents). See [`apply_default_job_folds`](Self::apply_default_job_folds).
        let jobs_tree = {
            let entries = self.jobs_view.jobs();
            let ids: std::collections::HashSet<&str> =
                entries.iter().map(|e| e.id.as_str()).collect();
            let parent_ids: std::collections::HashSet<&str> = entries
                .iter()
                .filter_map(|e| e.parent_id.as_deref())
                .collect();
            let children = entries.iter().filter(|e| e.parent_id.is_some()).count();
            // A root is a top-level row: no parent, or an orphan whose parent isn't
            // in the set (facett promotes those to the top level too).
            let roots = entries
                .iter()
                .filter(|e| e.parent_id.as_deref().is_none_or(|p| !ids.contains(p)))
                .count();
            // Parents that are actually present as rows (have at least one child).
            let parents = entries
                .iter()
                .filter(|e| parent_ids.contains(e.id.as_str()))
                .count();
            let folded: Vec<&str> = entries
                .iter()
                .filter(|e| parent_ids.contains(e.id.as_str()) && self.jobs_view.is_folded(&e.id))
                .map(|e| e.id.as_str())
                .collect();
            serde_json::json!({
                "roots": roots,
                "parents": parents,
                "children": children,
                "folded": folded,
            })
        };
        serde_json::json!({
            "palette": self.theme.name,
            // The operation buttons this pane exposes — id ⇒ the gRPC RPC each
            // fires (CLI parity in the doc). The matrix walks this to prove every
            // root op is wired.
            "buttons": [
                { "id": "server_status",     "rpc": "Health.Ping",          "heavy": false },
                { "id": "add_workspace",     "rpc": "Workspaces.Register",  "heavy": false },
                { "id": "kill_workspace",    "rpc": "Workspaces.Remove",    "heavy": false, "confirm": true },
                { "id": "populate",          "rpc": "Workspaces.Fetch",     "heavy": false },
                { "id": "refresh",           "rpc": "Workspaces.Fetch",     "heavy": false },
                { "id": "populate_status",   "rpc": "Viz.CloneEvents",      "heavy": false },
                { "id": "workspaces_roster", "rpc": "Workspaces.PopulateStatus", "heavy": false },
                { "id": "jobs",              "rpc": "Viz.Jobs",             "heavy": false },
            ],
            // PO1: the multi-workspace POPULATE ROSTER this pane renders — every
            // served workspace × its rolled-up verdict (green/red/stale), member
            // count, last-synced, and the first failing member + its error. So the
            // matrix asserts the red/green roll-up + the failing member is surfaced
            // ("see what the user sees" as data, LAW #6). `green`/`red`/`stale`
            // counts give the at-a-glance verdict.
            "workspaces": match &self.roster {
                None => serde_json::json!({ "loaded": false }),
                Some(Err(e)) => serde_json::json!({ "loaded": true, "ok": false, "error": e }),
                Some(Ok(rows)) => serde_json::json!({
                    "loaded": true,
                    "ok": true,
                    "total": rows.len(),
                    "green": rows.iter().filter(|r| r.populate == "green").count(),
                    "red": rows.iter().filter(|r| r.populate == "red").count(),
                    "stale": rows.iter().filter(|r| r.populate == "stale").count(),
                    "rows": rows.iter().map(|r| serde_json::json!({
                        "name": r.name,
                        "mode": r.mode,
                        "members": r.members,
                        "last_synced": r.last_synced,
                        "populate": r.populate,
                        "failing_member": r.failing_member,
                        "last_error": r.last_error,
                    })).collect::<Vec<_>>(),
                }),
            },
            // 🧰 The unified job list (the facett-jobview component's own state).
            "jobs": self.jobs_view.state_json(),
            // The Jobs panel's TREE shape (computed above), surfaced at the nornir
            // level so a robot test can assert nesting + the default-open decision.
            "jobs_tree": jobs_tree,
            "jobs_error": self.jobs_error,
            "add_form": {
                "name": self.new_name,
                "descriptor": self.new_descriptor,
                "mode": self.new_mode,
                "poll": self.new_poll,
            },
            "kill": { "target": self.kill_target, "armed": self.kill_armed },
            "results": {
                "add": res(&self.last_add),
                "kill": res(&self.last_kill),
                "populate": res(&self.last_populate),
                "refresh": res(&self.last_refresh),
            },
            "server": match &self.server_info {
                None => serde_json::json!({ "pinged": false }),
                Some(Ok(i)) => serde_json::json!({
                    "pinged": true, "ok": true,
                    "status": i.status, "version": i.version, "repo_count": i.repo_count,
                }),
                Some(Err(e)) => serde_json::json!({ "pinged": true, "ok": false, "error": e }),
            },
            // PLAN #6: the per-member populate status this pane renders — every
            // member's clone outcome (ok/error + detail + when) for the active
            // workspace, so the matrix can assert the failed member + error is
            // surfaced ("see what the user sees" as data, LAW #6).
            "populate_status": match &self.clone_events {
                None => serde_json::json!({ "loaded": false }),
                Some(Err(e)) => serde_json::json!({ "loaded": true, "ok": false, "error": e }),
                Some(Ok(rows)) => serde_json::json!({
                    "loaded": true,
                    "ok": true,
                    "workspace": self.clone_events_ws,
                    "total": rows.len(),
                    "failed": rows.iter().filter(|r| r.status == "error").count(),
                    "members": rows.iter().map(|r| serde_json::json!({
                        "member": r.member,
                        "op": r.op,
                        "status": r.status,
                        "detail": r.detail,
                        "remote": r.remote,
                        "ts": crate::warehouse::clone_events::ts_to_rfc3339(r.ts_micros),
                        "elapsed_ms": r.elapsed_ms,
                    })).collect::<Vec<_>>(),
                }),
            },
        })
    }

    /// Test/headless helper: inject populate-status rows directly (the same rows a
    /// `Viz.CloneEvents` read would yield) so the matrix can assert `state_json`
    /// surfaces a failed member + its error without a live server. (LAW: tests
    /// inject real values + assert real output.)
    pub fn set_clone_events_for_test(&mut self, workspace: &str, rows: Vec<CloneEventRow>) {
        self.clone_events_ws = workspace.to_string();
        self.clone_events = Some(Ok(rows));
    }

    /// PO1 test/headless helper: inject the multi-workspace populate ROSTER rows
    /// directly (the same rows a `Workspaces.PopulateStatus` read would yield), so
    /// the matrix asserts `state_json["nornir"]["workspaces"]` surfaces every
    /// workspace's verdict + the failing member's error without a live server.
    pub fn set_roster_for_test(&mut self, rows: Vec<RosterRow>) {
        self.roster = Some(Ok(rows));
        self.roster_loaded = true;
    }

    /// Test/headless helper: inject job-ledger rows directly (the same
    /// [`crate::jobs::JobRecord`]s a `Viz.Jobs` read would yield), mapped through
    /// the real [`job_entry`] path, so the matrix can assert
    /// `state_json["nornir"]["jobs"]` renders a **queued** job as queued (the serial
    /// scheduler's visible-wait UX) without a live server. (LAW: inject real
    /// values + assert real rendered output.)
    pub fn set_jobs_for_test(&mut self, recs: &[crate::jobs::JobRecord]) {
        self.jobs_view
            .set_jobs(recs.iter().map(job_entry).collect());
        // Exercise the same default-open rule the live poll applies, so a state
        // test sees the real fold decision (done parents collapsed, in-progress
        // ones expanded) — not just the raw facett default.
        self.apply_default_job_folds(recs);
    }

    /// Test/headless helper: latch a just-registered workspace name directly (as a
    /// successful `Workspaces.Register` Add would), so a state test can drive the
    /// app's auto-SELECT wiring (`take_just_registered` → `switch_workspace`)
    /// without a live server.
    pub fn set_just_registered_for_test(&mut self, name: &str) {
        self.just_registered = Some(name.to_string());
    }
}

/// PO1: load the multi-workspace populate ROSTER. Remote reads the
/// `Workspaces.PopulateStatus` roll-up RPC (the whole roster in one round-trip,
/// a compact summary that can't overflow the gRPC cap). Local mode has a single
/// served workspace (the one the viz is pointed at), so it rolls THAT workspace's
/// warehouse `clone_events` up into one [`RosterRow`] via the SAME shared verdict
/// the server uses. Returns the roster rows or a human error string.
fn load_roster(
    srv: Option<&Server>,
    _workspaces: &[String],
    active_ws: &str,
    local_warehouse: Option<&PathBuf>,
) -> Result<Vec<RosterRow>, String> {
    if let Some(srv) = srv {
        // The remote fetch decodes the wire-stable `nornir_wire::view::rows::RosterRow`
        // mirror (the Step-4 decoupling); map it back to the core `RosterRow` the
        // pane still renders (the pane keeps the core type until it moves).
        return remote::fetch_populate_status(&srv.endpoint, &srv.token)
            .map(|rows| rows.into_iter().map(RosterRow::from).collect())
            .map_err(|e| format!("{e:#}"));
    }
    // Local mode: one served workspace — roll its clone_events up into a row.
    if active_ws.is_empty() {
        return Ok(Vec::new());
    }
    let rows = load_clone_events(None, active_ws, local_warehouse)?;
    let verdict = crate::warehouse::clone_events::workspace_populate_verdict(&rows);
    Ok(vec![RosterRow {
        name: active_ws.to_string(),
        mode: "local".into(),
        members: verdict.members,
        last_synced: verdict.last_synced.clone(),
        populate: verdict.state,
        failing_member: verdict.failing_member,
        last_error: verdict.last_error,
    }])
}

/// Load the active workspace's `clone_events` populate outcomes: remote via the
/// `Viz.CloneEvents` RPC, local via the workspace warehouse (lock-tolerant
/// `open_read_only`). Returns rows newest-first or a human error string.
fn load_clone_events(
    srv: Option<&Server>,
    active_ws: &str,
    local_warehouse: Option<&PathBuf>,
) -> Result<Vec<CloneEventRow>, String> {
    if let Some(srv) = srv {
        // Decode the wire mirror rows, then map back to the core `CloneEventRow`
        // the pane + `workspace_populate_verdict` consume (see load_roster).
        return remote::fetch_clone_events(&srv.endpoint, &srv.token, active_ws)
            .map(|rows| rows.into_iter().map(CloneEventRow::from).collect())
            .map_err(|e| format!("{e:#}"));
    }
    // Local mode: read the workspace's own warehouse without contending the
    // single-writer lock.
    let Some(root) = local_warehouse else {
        return Ok(Vec::new());
    };
    use crate::warehouse::clone_events::{CloneSelector, query_clone_events};
    let wh = crate::warehouse::iceberg::IcebergWarehouse::open_read_only(root)
        .map_err(|e| format!("open warehouse: {e:#}"))?;
    wh.block_on(query_clone_events(
        &wh,
        &CloneSelector::Workspace(active_ws.to_string()),
    ))
    .map_err(|e| format!("{e:#}"))
}

/// Load the job ledger for `active_ws`: remote via `Viz.Jobs`, else the local
/// `jobs.redb` beside the warehouse (lock-tolerant read). Newest-first.
fn load_jobs(
    srv: Option<&Server>,
    active_ws: &str,
    local_warehouse: Option<&PathBuf>,
) -> Result<Vec<crate::jobs::JobRecord>, String> {
    if let Some(srv) = srv {
        return remote::fetch_jobs(&srv.endpoint, &srv.token, active_ws, "", 200)
            .map_err(|e| format!("{e:#}"));
    }
    let Some(root) = local_warehouse else {
        return Ok(Vec::new());
    };
    crate::jobs::JobStore::open_read_only(root)
        .and_then(|s| s.list(&crate::jobs::JobSelector::All))
        .map_err(|e| format!("{e:#}"))
}

/// Map a nornir [`crate::jobs::JobRecord`] onto the host-agnostic facett
/// [`JobEntry`]. The `result_ref` becomes a `meta` row when present.
fn job_entry(r: &crate::jobs::JobRecord) -> JobEntry {
    let meta = if r.result_ref.is_empty() {
        Vec::new()
    } else {
        vec![("result".to_string(), r.result_ref.clone())]
    };
    let entry = JobEntry {
        id: r.job_id.clone(),
        kind: r.kind.clone(),
        target: r.target.clone(),
        status: JobStatus::parse(&r.status),
        started_micros: r.ts_start_micros,
        elapsed_ms: r.elapsed_ms,
        detail: r.detail_json.clone(),
        meta,
        // Hierarchy: a populate's per-member clone + build children carry the
        // parent populate job's id, so facett-jobview folds them underneath it.
        parent_id: r.parent_id.clone(),
    };
    // **PHASE.** `JobRecord::phase` is written durably by jera's `daemon_report_sink`
    // and preserved across `finish()`, and this mapper dropped it — as did
    // `dwarves_embed::jobsfleet::entry_from_record`, the other host that turns a
    // `JobRecord` into a `JobEntry`. Two hosts, one field, zero surfacing: everything
    // the write side gained (the mirror, the preserve limb) landed behind a read side
    // that threw the value away, so `cutting` on disk rendered as a bare `running`.
    //
    // Stamped through `JobEntry::with_phase`, NOT a local `meta.push(("phase", …))`:
    // that writer owns the key's spelling and the "blank is not a phase" rule for every
    // host, so the two mappers cannot drift into agreeing-until-they-do not (LAW 5).
    // `fraction()` is the durable mirror of the progress tick and rides the existing
    // `with_progress` writer for the same reason.
    //
    // NOT carried here, and named rather than hidden: this mapper still surfaces far
    // less than dwarves-embed's (no `node`, no `by`/actor, no `workspace` meta, and
    // `detail` is the raw `detail_json` rather than a folded summary). Unifying the two
    // into one mapper needs a home both repos can reach — `facett-jobview` with an
    // optional `jera` dep — which is a bigger change than this field.
    let Some(dp) = r.daemon_phase() else { return entry };
    let entry = entry.with_phase(Some(dp.name.as_str()));
    match dp.fraction() {
        Some(f) => entry.with_progress(f),
        None => entry,
    }
}

/// Render an `Ok`/`Err` result line in the palette colours (shared by the ops).
fn result_line(ui: &mut egui::Ui, theme: Theme, r: &Option<Result<String, String>>) {
    let _ = theme;
    match r {
        Some(Ok(m)) => {
            ui.colored_label(GREEN, format!("✓ {m}"));
        }
        Some(Err(e)) => {
            ui.colored_label(RED, format!("✗ {e}"));
        }
        None => {}
    }
}

/// Thin wrapper over `Workspaces.Fetch` that yields a one-line human summary
/// (Populate uses `background=true`; Refresh uses `force=true`).
fn fetch(
    endpoint: &str,
    token: &str,
    name: &str,
    force: bool,
    background: bool,
) -> anyhow::Result<String> {
    // `fetch_workspace` only exposes `force`; the background path is the same
    // RPC with `background=true`, surfaced here via the dedicated helper.
    let (fetched, changed, errors, snapshot) = if background {
        remote::populate_workspace(endpoint, token, name)?
    } else {
        remote::fetch_workspace(endpoint, token, name, force)?
    };
    let snap = if snapshot.is_empty() {
        String::new()
    } else {
        format!(" → snapshot {}", &snapshot[..12.min(snapshot.len())])
    };
    let errs = if errors.is_empty() {
        String::new()
    } else {
        format!(", {} error(s)", errors.len())
    };
    Ok(format!(
        "{} fetched, {} changed{snap}{errs}",
        fetched,
        changed.len()
    ))
}

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

    /// LAW 1 (inject-assert) + LAW #6 (components emit what they render): inject a
    /// `clone_events` row-set with one OK and one FAILED member into the pane, then
    /// assert `state_json` surfaces the failed member, its error detail, the
    /// failed-count, and the `Viz.CloneEvents` button — "see what the user sees" as
    /// data. Not a "didn't panic" test.
    #[test]
    fn state_json_surfaces_failed_member_populate_status() {
        let mut view = NornirRootView::remote();
        view.set_clone_events_for_test(
            "nordisk",
            vec![
                CloneEventRow {
                    ts_micros: 2,
                    workspace: "nordisk".into(),
                    member: "korp".into(),
                    remote: "https://github.com/nordisk/korp".into(),
                    op: "clone-fetch".into(),
                    status: "error".into(),
                    detail: "clone-fetch …: Couldn't obtain Username".into(),
                    elapsed_ms: 7,
                },
                CloneEventRow {
                    ts_micros: 1,
                    workspace: "nordisk".into(),
                    member: "facett".into(),
                    remote: "git@github.com:nordisk/facett.git".into(),
                    op: "clone-fetch".into(),
                    status: "ok".into(),
                    detail: "abc123".into(),
                    elapsed_ms: 42,
                },
            ],
        );

        let s = view.state_json();
        let ps = &s["populate_status"];
        assert_eq!(ps["loaded"], true);
        assert_eq!(ps["ok"], true);
        assert_eq!(ps["workspace"], "nordisk");
        assert_eq!(ps["total"], 2);
        assert_eq!(ps["failed"], 1, "one member failed to populate");

        let members = ps["members"].as_array().expect("members array");
        let korp = members
            .iter()
            .find(|m| m["member"] == "korp")
            .expect("failed member korp is surfaced");
        assert_eq!(korp["status"], "error");
        assert!(
            korp["detail"]
                .as_str()
                .unwrap()
                .contains("Couldn't obtain Username"),
            "the error detail is readable in state_json, got: {}",
            korp["detail"]
        );
        assert_eq!(korp["op"], "clone-fetch");
        assert!(
            korp["ts"].as_str().unwrap().contains('T'),
            "ts rendered as RFC3339"
        );

        // The populate-status button is discoverable by the matrix.
        let buttons = s["buttons"].as_array().unwrap();
        assert!(
            buttons
                .iter()
                .any(|b| b["id"] == "populate_status" && b["rpc"] == "Viz.CloneEvents"),
            "the populate-status surface is wired to Viz.CloneEvents"
        );
    }

    /// PO1 (inject-assert) + LAW #6: seed a multi-workspace roster — one RED
    /// workspace (a failing member) and one GREEN — then assert
    /// `state_json["nornir"]["workspaces"]` surfaces both verdicts, the green/red
    /// counts, AND the failing member + its error. The roster's red/green roll-up,
    /// as data. Also asserts the roster button is wired to Workspaces.PopulateStatus.
    #[test]
    fn state_json_surfaces_workspace_roster_red_and_green() {
        let mut view = NornirRootView::remote();
        view.set_roster_for_test(vec![
            RosterRow {
                name: "nordisk".into(),
                mode: "monitored".into(),
                members: 8,
                last_synced: "2026-06-23T10:00:00+00:00".into(),
                populate: "red".into(),
                failing_member: "korp".into(),
                last_error: "Couldn't obtain Username".into(),
            },
            RosterRow {
                name: "holger".into(),
                mode: "monitored".into(),
                members: 2,
                last_synced: "2026-06-23T10:01:00+00:00".into(),
                populate: "green".into(),
                ..Default::default()
            },
        ]);

        let s = view.state_json();
        let roster = &s["workspaces"];
        assert_eq!(roster["loaded"], true);
        assert_eq!(roster["ok"], true);
        assert_eq!(roster["total"], 2);
        assert_eq!(roster["green"], 1);
        assert_eq!(roster["red"], 1, "one workspace failed to populate");

        let rows = roster["rows"].as_array().unwrap();
        let nordisk = rows.iter().find(|r| r["name"] == "nordisk").unwrap();
        assert_eq!(nordisk["populate"], "red");
        assert_eq!(nordisk["failing_member"], "korp");
        assert!(
            nordisk["last_error"]
                .as_str()
                .unwrap()
                .contains("Couldn't obtain Username"),
            "the failing member's error is readable in state_json"
        );
        let holger = rows.iter().find(|r| r["name"] == "holger").unwrap();
        assert_eq!(holger["populate"], "green");

        // The roster surface is discoverable + wired to the roll-up RPC.
        let buttons = s["buttons"].as_array().unwrap();
        assert!(
            buttons
                .iter()
                .any(|b| b["id"] == "workspaces_roster" && b["rpc"] == "Workspaces.PopulateStatus"),
            "the workspace roster is wired to Workspaces.PopulateStatus"
        );
    }

    /// Serial-scheduler UX (inject-assert) + LAW #6: inject a job ledger holding a
    /// **running** bench and a second bench **queued** behind it (exactly the rows
    /// the serial scheduler writes — one exclusive job runs, the next waits
    /// `queued`). Assert `state_json["nornir"]["jobs"]` renders the queued job AS
    /// queued, counts one running + one queued, and that the Jobs surface is wired
    /// to `Viz.Jobs`. This is the "see the queue" guarantee, as data.
    #[test]
    fn state_json_surfaces_queued_bench_behind_running_bench() {
        use crate::jobs::{JobRecord, kind, status};
        let mut view = NornirRootView::remote();
        view.set_jobs_for_test(&[
            JobRecord {
                job_id: "running-bench".into(),
                kind: kind::BENCH_RUN.into(),
                target: "alpha".into(),
                workspace: "nordisk".into(),
                status: status::RUNNING.into(),
                ts_start_micros: 2,
                ts_end_micros: None,
                elapsed_ms: None,
                detail_json: String::new(),
                result_ref: String::new(),
                parent_id: None,
                node: None,
                phase: None,
                actor: None,
                actor_method: None,
            },
            JobRecord {
                job_id: "queued-bench".into(),
                kind: kind::BENCH_RUN.into(),
                target: "beta".into(),
                workspace: "nordisk".into(),
                status: status::QUEUED.into(),
                ts_start_micros: 1,
                ts_end_micros: None,
                elapsed_ms: None,
                detail_json: String::new(),
                result_ref: String::new(),
                parent_id: None,
                node: None,
                phase: None,
                actor: None,
                actor_method: None,
            },
        ]);

        let s = view.state_json();
        let jobs = &s["jobs"];
        assert_eq!(jobs["count"], 2);
        assert_eq!(jobs["running"], 1, "exactly one bench runs");
        assert_eq!(
            jobs["queued"], 1,
            "the second bench is visibly queued behind it"
        );

        let rows = jobs["jobs"].as_array().expect("jobs array");
        let queued = rows
            .iter()
            .find(|j| j["id"] == "queued-bench")
            .expect("the queued bench is rendered, not hidden");
        assert_eq!(
            queued["status"], "queued",
            "the waiting bench renders as queued (the visible-wait UX)"
        );
        let running = rows.iter().find(|j| j["id"] == "running-bench").unwrap();
        assert_eq!(running["status"], "running");

        // The Jobs surface is discoverable + wired to Viz.Jobs.
        let buttons = s["buttons"].as_array().unwrap();
        assert!(
            buttons
                .iter()
                .any(|b| b["id"] == "jobs" && b["rpc"] == "Viz.Jobs"),
            "the jobs surface is wired to Viz.Jobs"
        );
    }

    /// Build a populate-style job record (parent or child) for the tree tests.
    fn job_rec(
        id: &str,
        kind: &str,
        status: &str,
        start: i64,
        parent: Option<&str>,
    ) -> crate::jobs::JobRecord {
        crate::jobs::JobRecord {
            job_id: id.into(),
            kind: kind.into(),
            target: id.into(),
            workspace: "nordisk".into(),
            status: status.into(),
            ts_start_micros: start,
            ts_end_micros: None,
            elapsed_ms: None,
            detail_json: String::new(),
            result_ref: String::new(),
            parent_id: parent.map(|p| p.to_string()),
            node: None,
            phase: None,
            actor: None,
            actor_method: None,
        }
    }

    /// The Jobs panel is a TREE: a running populate parent with two member-clone
    /// children NESTS the children under it (depth 1), stays EXPANDED (in-progress
    /// work visible), and the nornir + facett state surfaces both report the shape.
    #[test]
    fn jobs_panel_nests_children_under_running_parent_and_stays_expanded() {
        use crate::jobs::{kind, status};
        let mut view = NornirRootView::remote();
        view.set_jobs_for_test(&[
            job_rec("pop", kind::WORKSPACE_POPULATE, status::RUNNING, 100, None),
            job_rec("m1", kind::WORKSPACE_CLONE, status::DONE, 110, Some("pop")),
            job_rec(
                "m2",
                kind::WORKSPACE_CLONE,
                status::RUNNING,
                120,
                Some("pop"),
            ),
        ]);

        let s = view.state_json();

        // nornir's own tree surface: one root parent, two children, nothing folded
        // (the parent is still running, so the default-open rule keeps it expanded).
        let tree = &s["jobs_tree"];
        assert_eq!(
            tree["roots"], 1,
            "the populate parent is the single root row"
        );
        assert_eq!(tree["parents"], 1, "one row has children");
        assert_eq!(tree["children"], 2, "two member clones are sub-jobs");
        assert_eq!(
            tree["folded"].as_array().unwrap().len(),
            0,
            "a running parent stays EXPANDED by default"
        );

        // facett's display tree: parent at depth 0, both children indented at depth 1.
        let rows = s["jobs"]["jobs"].as_array().expect("jobs tree rows");
        let parent = rows
            .iter()
            .find(|r| r["id"] == "pop")
            .expect("parent row present");
        assert_eq!(parent["depth"], 0, "parent at top level");
        for child in ["m1", "m2"] {
            let row = rows
                .iter()
                .find(|r| r["id"] == child)
                .expect("child row present");
            assert_eq!(row["depth"], 1, "{child} nested under its parent");
            assert_eq!(
                row["parent_id"], "pop",
                "{child} links to the populate parent"
            );
        }
    }

    /// The collapse rule + toggle: a FULLY-DONE populate parent (all children done)
    /// is auto-COLLAPSED by default (tidy), its hidden kids replaced by a roll-up
    /// summary; a failed descendant instead keeps the parent EXPANDED so the error
    /// stays visible.
    #[test]
    fn jobs_panel_collapses_done_parent_but_keeps_failing_one_expanded() {
        use crate::jobs::{kind, status};

        // (a) Every descendant finished cleanly → the parent folds by default.
        let mut done = NornirRootView::remote();
        done.set_jobs_for_test(&[
            job_rec("pop", kind::WORKSPACE_POPULATE, status::DONE, 100, None),
            job_rec("m1", kind::WORKSPACE_CLONE, status::DONE, 110, Some("pop")),
            job_rec("scan", kind::KNOWLEDGE_SCAN, status::DONE, 120, Some("pop")),
        ]);
        let s = done.state_json();
        let folded: Vec<&str> = s["jobs_tree"]["folded"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap())
            .collect();
        assert_eq!(
            folded,
            ["pop"],
            "a fully-done parent is collapsed by default"
        );
        // Folded: only the parent row shows; its kids are rolled up into a summary.
        let rows = s["jobs"]["jobs"].as_array().unwrap();
        assert!(
            rows.iter().all(|r| r["depth"] == 0),
            "no child rows while folded"
        );
        let parent = rows.iter().find(|r| r["id"] == "pop").unwrap();
        assert!(
            parent["child_summary"].as_str().unwrap().contains("2/2 ✓"),
            "the folded parent rolls its done children up: {}",
            parent["child_summary"]
        );

        // (b) A failed child keeps the parent EXPANDED so the failure is visible.
        let mut failing = NornirRootView::remote();
        failing.set_jobs_for_test(&[
            job_rec("pop", kind::WORKSPACE_POPULATE, status::DONE, 100, None),
            job_rec("m1", kind::WORKSPACE_CLONE, status::DONE, 110, Some("pop")),
            job_rec("sec", kind::SECURITY_SCAN, status::FAILED, 120, Some("pop")),
        ]);
        let s = failing.state_json();
        assert_eq!(
            s["jobs_tree"]["folded"].as_array().unwrap().len(),
            0,
            "a parent with a failed descendant stays expanded"
        );
        // The failed child is visible nested under the parent.
        let rows = s["jobs"]["jobs"].as_array().unwrap();
        let sec = rows
            .iter()
            .find(|r| r["id"] == "sec")
            .expect("failed child visible");
        assert_eq!(sec["depth"], 1);
        assert_eq!(sec["status"], "failed");
    }
}