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

nordisk/nornir

src/viz/models_tab.rs

//! 🧠 **Models tab** — browse known LLM / embedding models, pick a **device /
//! backend** (CPU / CUDA / ROCm), and **download** one as a nornir **job**.
//!
//! Each download is a job in the job list — the same `nornir-jobs` +
//! [`JobSink`](crate::jobs::JobSink) seam the Build-tab test-boot uses: a
//! [`JobHandle`](crate::jobs::JobHandle) opens a `running` job, the download runs
//! through the pluggable [`ModelDownloader`] seam, and the job finishes / fails /
//! cancels as the download does — with a live progress row + a Cancel button.
//!
//! The downloader is a trait so CI injects a [`FakeDownloader`] (no network, no
//! disk pull) and drives a real progress → done cycle; the runtime downloader
//! ([`OllamaPullDownloader`]) shells out to `ollama pull` and lands models under a
//! `/home` models dir (never on the source disk). HuggingFace / candle model
//! pulls (hf-hub) are a documented follow-up.
//!
//! The tab surfaces which backend is active (candle / ollama / ort), the selected
//! device, and whether a GPU runtime lib (`libonnxruntime` for CUDA/ROCm) is
//! present — the `embed-ort` dlopen check. Everything the pane renders is in
//! [`ModelsTab::state_json`] (LAW #6) so a headless robot asserts a download job
//! is created + progresses via the fake, no pixels.

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

use eframe::egui;

use crate::jobs::{JobHandle, JobSink, kind};

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

// ── Model catalog ────────────────────────────────────────────────────────────

/// The inference/embedding backend a model runs on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backend {
    /// Candle (Rust) — HuggingFace-hosted weights (`gen-candle` / embeddings).
    Candle,
    /// Ollama — pulled + served by a local `ollama` daemon (`gen-ollama`).
    Ollama,
    /// ONNX Runtime (`ort`, dlopen'd) — CPU or CUDA/ROCm EP (`embed-ort`).
    Ort,
}

impl Backend {
    pub fn as_str(self) -> &'static str {
        match self {
            Backend::Candle => "candle",
            Backend::Ollama => "ollama",
            Backend::Ort => "ort",
        }
    }
    pub fn label(self) -> &'static str {
        match self {
            Backend::Candle => "Candle",
            Backend::Ollama => "Ollama",
            Backend::Ort => "ONNX Runtime",
        }
    }
}

/// One browsable model in the catalog.
pub struct ModelSpec {
    pub id: &'static str,
    pub backend: Backend,
    pub note: &'static str,
}

/// The known models the tab lists. Ollama entries download via `ollama pull`; the
/// candle/ort entries are HuggingFace-hosted (hf-hub download is a follow-up).
pub const CATALOG: &[ModelSpec] = &[
    ModelSpec {
        id: "llama3.2",
        backend: Backend::Ollama,
        note: "3B general chat model",
    },
    ModelSpec {
        id: "qwen2.5-coder:7b",
        backend: Backend::Ollama,
        note: "code-specialized model",
    },
    ModelSpec {
        id: "nomic-embed-text",
        backend: Backend::Ollama,
        note: "text embeddings",
    },
    ModelSpec {
        id: "jinaai/jina-embeddings-v2-base-code",
        backend: Backend::Candle,
        note: "code embeddings (HuggingFace)",
    },
];

/// The device / GPU backend a download targets — mirrors the binary's
/// `--cuda /opt/nornir/cuda` + `--rocm /opt/nornir/rocm` args + the gen-candle /
/// ort device selection (a GPU-less box runs CPU).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Device {
    Cpu,
    Cuda,
    Rocm,
}

impl Device {
    pub const ALL: [Device; 3] = [Device::Cpu, Device::Cuda, Device::Rocm];
    pub fn as_str(self) -> &'static str {
        match self {
            Device::Cpu => "cpu",
            Device::Cuda => "cuda",
            Device::Rocm => "rocm",
        }
    }
    pub fn label(self) -> &'static str {
        match self {
            Device::Cpu => "CPU",
            Device::Cuda => "CUDA (NVIDIA)",
            Device::Rocm => "ROCm (AMD)",
        }
    }
}

// ── Downloader seam ──────────────────────────────────────────────────────────

/// The terminal + in-flight state of one download.
#[derive(Clone, Debug, PartialEq)]
pub enum DlStatus {
    Downloading,
    Done,
    Failed(String),
    Cancelled,
}

impl DlStatus {
    pub fn as_str(&self) -> &'static str {
        match self {
            DlStatus::Downloading => "downloading",
            DlStatus::Done => "done",
            DlStatus::Failed(_) => "failed",
            DlStatus::Cancelled => "cancelled",
        }
    }
    pub fn is_terminal(&self) -> bool {
        !matches!(self, DlStatus::Downloading)
    }
}

/// A request to download one model.
#[derive(Clone, Debug)]
pub struct DownloadReq {
    pub id: String,
    pub backend: Backend,
    pub device: Device,
    pub dest: PathBuf,
}

/// A live download the tab polls for progress + status and can cancel.
pub trait DownloadProc: Send {
    /// Fractional progress `0.0..=1.0`, or `None` when indeterminate.
    fn progress(&self) -> Option<f32>;
    /// The current status.
    fn status(&self) -> DlStatus;
    /// Request cancellation (idempotent).
    fn cancel(&mut self);
}

/// The download side-work seam — real = [`OllamaPullDownloader`], tests inject a
/// [`FakeDownloader`].
pub trait ModelDownloader: Send + Sync {
    fn start(&self, req: &DownloadReq) -> Result<Box<dyn DownloadProc>, String>;
}

/// The runtime downloader: shells out to `ollama pull <id>` and reports progress
/// parsed from its output; models land under `req.dest` (`OLLAMA_MODELS`).
pub struct OllamaPullDownloader;

struct CmdState {
    frac: Option<f32>,
    status: DlStatus,
    pid: i32,
    cancelled: bool,
}

struct CmdProc {
    state: Arc<Mutex<CmdState>>,
}

impl DownloadProc for CmdProc {
    fn progress(&self) -> Option<f32> {
        self.state.lock().unwrap().frac
    }
    fn status(&self) -> DlStatus {
        self.state.lock().unwrap().status.clone()
    }
    fn cancel(&mut self) {
        let mut g = self.state.lock().unwrap();
        g.cancelled = true;
        if !g.status.is_terminal() {
            // SAFETY: sending SIGTERM to a pid we spawned. The waiter thread reaps.
            unsafe { libc::kill(g.pid, libc::SIGTERM) };
        }
    }
}

/// Extract a trailing `NN%` from an `ollama pull` progress line → `0.0..=1.0`.
fn parse_percent(line: &str) -> Option<f32> {
    for tok in line.split_whitespace().rev() {
        if let Some(num) = tok.strip_suffix('%') {
            if let Ok(pct) = num.parse::<f32>() {
                return Some((pct / 100.0).clamp(0.0, 1.0));
            }
        }
    }
    None
}

impl ModelDownloader for OllamaPullDownloader {
    fn start(&self, req: &DownloadReq) -> Result<Box<dyn DownloadProc>, String> {
        if req.backend != Backend::Ollama {
            return Err(format!(
                "downloading a {} model (HuggingFace hf-hub) is not wired yet — a follow-up; \
                 pick an Ollama model, or the fake downloader drives this in tests.",
                req.backend.label()
            ));
        }
        std::fs::create_dir_all(&req.dest).ok();
        let mut child = std::process::Command::new("ollama")
            .arg("pull")
            .arg(&req.id)
            .env("OLLAMA_MODELS", &req.dest)
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .spawn()
            .map_err(|e| format!("spawn `ollama pull {}`: {e}", req.id))?;
        let pid = child.id() as i32;
        let state = Arc::new(Mutex::new(CmdState {
            frac: None,
            status: DlStatus::Downloading,
            pid,
            cancelled: false,
        }));
        let stderr = child.stderr.take();
        let worker = Arc::clone(&state);
        std::thread::spawn(move || {
            use std::io::{BufRead, BufReader};
            // ollama writes its progress bars to stderr.
            if let Some(err) = stderr {
                for line in BufReader::new(err).lines().map_while(Result::ok) {
                    if let Some(f) = parse_percent(&line) {
                        worker.lock().unwrap().frac = Some(f);
                    }
                }
            }
            let ok = child.wait().map(|s| s.success()).unwrap_or(false);
            let mut g = worker.lock().unwrap();
            if g.cancelled {
                g.status = DlStatus::Cancelled;
            } else if ok {
                g.frac = Some(1.0);
                g.status = DlStatus::Done;
            } else {
                g.status = DlStatus::Failed("`ollama pull` exited nonzero".into());
            }
        });
        Ok(Box::new(CmdProc { state }))
    }
}

/// A deterministic downloader for tests: advances `steps` ticks (one per
/// [`DownloadProc::progress`] call) then reports [`DlStatus::Done`] — no network,
/// no disk. Records every request it saw.
pub struct FakeDownloader {
    steps: u32,
    fail: Option<String>,
    seen: Mutex<Vec<DownloadReq>>,
}

impl FakeDownloader {
    /// A fake that succeeds after `steps` progress ticks.
    pub fn new(steps: u32) -> Self {
        Self {
            steps: steps.max(1),
            fail: None,
            seen: Mutex::new(Vec::new()),
        }
    }
    /// A fake whose `start` fails (drives the open-then-fail job path).
    pub fn failing(reason: impl Into<String>) -> Self {
        Self {
            steps: 1,
            fail: Some(reason.into()),
            seen: Mutex::new(Vec::new()),
        }
    }
    /// The requests this fake has been sent (for assertions).
    pub fn requests(&self) -> Vec<DownloadReq> {
        self.seen.lock().unwrap().clone()
    }
}

struct FakeProc {
    tick: Mutex<u32>,
    steps: u32,
    cancelled: Mutex<bool>,
}

impl DownloadProc for FakeProc {
    fn progress(&self) -> Option<f32> {
        let mut t = self.tick.lock().unwrap();
        if *t < self.steps && !*self.cancelled.lock().unwrap() {
            *t += 1;
        }
        Some((*t as f32 / self.steps as f32).clamp(0.0, 1.0))
    }
    fn status(&self) -> DlStatus {
        if *self.cancelled.lock().unwrap() {
            return DlStatus::Cancelled;
        }
        if *self.tick.lock().unwrap() >= self.steps {
            DlStatus::Done
        } else {
            DlStatus::Downloading
        }
    }
    fn cancel(&mut self) {
        *self.cancelled.lock().unwrap() = true;
    }
}

impl ModelDownloader for FakeDownloader {
    fn start(&self, req: &DownloadReq) -> Result<Box<dyn DownloadProc>, String> {
        self.seen.lock().unwrap().push(req.clone());
        if let Some(reason) = &self.fail {
            return Err(reason.clone());
        }
        Ok(Box::new(FakeProc {
            tick: Mutex::new(0),
            steps: self.steps,
            cancelled: Mutex::new(false),
        }))
    }
}

// ── GPU / backend probe ──────────────────────────────────────────────────────

/// What accelerator runtime is present — the `embed-ort` dlopen check + the
/// `--cuda`/`--rocm` pinned-runtime paths + which gen backends are compiled in.
#[derive(Clone, Debug)]
pub struct GpuProbe {
    /// `libonnxruntime.so` was found + verified to dlopen (the ort dynamic-load lib).
    pub onnxruntime: bool,
    /// `libcuda.so.1` loaded — an NVIDIA driver is present.
    pub cuda_driver: bool,
    /// A ROCm runtime is present (AMD).
    pub rocm: bool,
    /// A CUDA runtime is pinned at the documented `--cuda` path.
    pub cuda_pinned: bool,
    /// A ROCm runtime is pinned at the documented `--rocm` path.
    pub rocm_pinned: bool,
    pub gen_candle: bool,
    pub gen_ollama: bool,
    pub embed_ort: bool,
}

fn gpu_probe() -> GpuProbe {
    // The `embed-ort` dlopen check: find + verify `libonnxruntime.so`.
    #[cfg(feature = "embed-ort")]
    let onnxruntime = crate::vector::cuda::onnxruntime_dylib().is_some();
    #[cfg(not(feature = "embed-ort"))]
    let onnxruntime = false;
    #[cfg(feature = "embed-ort")]
    let cuda_driver = crate::vector::cuda::driver_present();
    #[cfg(not(feature = "embed-ort"))]
    let cuda_driver = false;
    #[cfg(feature = "embed-ort-rocm")]
    let rocm = crate::vector::rocm::available();
    #[cfg(not(feature = "embed-ort-rocm"))]
    let rocm = false;

    GpuProbe {
        onnxruntime,
        cuda_driver,
        rocm,
        cuda_pinned: Path::new("/opt/nornir/cuda").exists(),
        rocm_pinned: Path::new("/opt/nornir/rocm").exists(),
        gen_candle: cfg!(feature = "gen-candle"),
        gen_ollama: cfg!(feature = "gen-ollama"),
        embed_ort: cfg!(feature = "embed-ort"),
    }
}

// ── Live tab ─────────────────────────────────────────────────────────────────

/// One download job the tab tracks: the ledger handle + the live proc + its
/// last-observed progress/status.
struct DownloadJob {
    job_id: String,
    model: String,
    backend: Backend,
    device: Device,
    status: DlStatus,
    frac: Option<f32>,
    proc: Option<Box<dyn DownloadProc>>,
    handle: Option<JobHandle>,
}

/// 🧠 The Models tab: a model catalog + a device selector + a pluggable
/// [`ModelDownloader`], with each download tracked as a nornir job.
pub struct ModelsTab {
    downloader: Arc<dyn ModelDownloader>,
    sink: JobSink,
    workspace: String,
    /// Where downloads land (a `/home` models dir — never the source disk).
    dest: PathBuf,
    device: Device,
    selected: usize,
    jobs: Vec<DownloadJob>,
    gpu: GpuProbe,
    theme: Theme,
    /// Cancel requests raised by the pure `&mut` draw, drained by the live loop
    /// (mirrors the Build tab's pending-effect pattern).
    pending_cancel: RefCell<Vec<String>>,
    pending_download: RefCell<Vec<usize>>,
}

impl ModelsTab {
    /// A tab wired to the real [`OllamaPullDownloader`], a `sink` for the job
    /// ledger (use [`JobSink::noop`] when no warehouse is configured), and a
    /// `/home`-anchored models dir.
    pub fn new(sink: JobSink, workspace: impl Into<String>) -> Self {
        Self::with_downloader(Arc::new(OllamaPullDownloader), sink, workspace)
    }

    /// A tab with an injected [`ModelDownloader`] — the test seam.
    pub fn with_downloader(
        downloader: Arc<dyn ModelDownloader>,
        sink: JobSink,
        workspace: impl Into<String>,
    ) -> Self {
        Self {
            downloader,
            sink,
            workspace: workspace.into(),
            dest: default_models_dir(),
            device: Device::Cpu,
            selected: 0,
            jobs: Vec::new(),
            gpu: gpu_probe(),
            theme: Theme::default(),
            pending_cancel: RefCell::new(Vec::new()),
            pending_download: RefCell::new(Vec::new()),
        }
    }

    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// Inject a downloader — the test seam (a [`FakeDownloader`] drives the whole
    /// download → progress → done lifecycle with no network/disk).
    pub fn set_downloader_for_test(&mut self, downloader: Arc<dyn ModelDownloader>) {
        self.downloader = downloader;
    }

    /// **Start a download** for catalog entry `idx` on the selected device. Opens
    /// a `running` nornir job (kind = [`kind::MODEL_DOWNLOAD`]) and registers the
    /// live proc; a start failure opens + immediately fails the job (an honest
    /// terminal row). Returns the new job id.
    pub fn download(&mut self, idx: usize) -> String {
        let spec = &CATALOG[idx];
        let device = self.device;
        let req = DownloadReq {
            id: spec.id.to_string(),
            backend: spec.backend,
            device,
            dest: self.dest.join(spec.backend.as_str()),
        };
        let handle = JobHandle::start(
            self.sink.clone(),
            kind::MODEL_DOWNLOAD,
            spec.id,
            &self.workspace,
            serde_json::json!({ "backend": spec.backend.as_str(), "device": device.as_str() }),
        );
        let job_id = handle.job_id().to_string();
        match self.downloader.start(&req) {
            Ok(proc) => {
                nornir_testmatrix::functional_status(
                    "nornir/viz/models",
                    &format!("download_{}", spec.id),
                    true,
                    &format!(
                        "download '{}' ({}) started on {}",
                        spec.id,
                        spec.backend.as_str(),
                        device.as_str()
                    ),
                );
                self.jobs.push(DownloadJob {
                    job_id: job_id.clone(),
                    model: spec.id.to_string(),
                    backend: spec.backend,
                    device,
                    status: DlStatus::Downloading,
                    frac: None,
                    proc: Some(proc),
                    handle: Some(handle),
                });
            }
            Err(reason) => {
                nornir_testmatrix::functional_status(
                    "nornir/viz/models",
                    &format!("download_{}", spec.id),
                    false,
                    &format!("download '{}' FAILED to start: {reason}", spec.id),
                );
                handle.fail(&anyhow::anyhow!("{reason}"));
                self.jobs.push(DownloadJob {
                    job_id: job_id.clone(),
                    model: spec.id.to_string(),
                    backend: spec.backend,
                    device,
                    status: DlStatus::Failed(reason),
                    frac: None,
                    proc: None,
                    handle: None,
                });
            }
        }
        job_id
    }

    /// **Poll every download.** Read each live proc's progress + status; when a
    /// download reaches a terminal status, finish / fail the nornir job.
    pub fn poll(&mut self) {
        for job in &mut self.jobs {
            if job.status.is_terminal() {
                continue;
            }
            let Some(proc) = job.proc.as_ref() else {
                continue;
            };
            job.frac = proc.progress();
            let status = proc.status();
            if status != job.status {
                job.status = status.clone();
            }
            if status.is_terminal() {
                Self::terminate_job(job, &status);
            }
        }
    }

    /// **Cancel a download by job id.** Cancels the proc, flips the status, and
    /// writes the terminal ledger row. Returns whether a live job matched.
    pub fn cancel(&mut self, job_id: &str) -> bool {
        let Some(job) = self.jobs.iter_mut().find(|j| j.job_id == job_id) else {
            return false;
        };
        if job.status.is_terminal() {
            return false;
        }
        if let Some(proc) = job.proc.as_mut() {
            proc.cancel();
        }
        job.status = DlStatus::Cancelled;
        Self::terminate_job(job, &DlStatus::Cancelled);
        true
    }

    /// Write the terminal ledger row for a finished/failed/cancelled download.
    fn terminate_job(job: &mut DownloadJob, status: &DlStatus) {
        job.proc = None;
        let Some(handle) = job.handle.take() else {
            return;
        };
        match status {
            DlStatus::Done => handle.finish(
                serde_json::json!({ "model": job.model, "backend": job.backend.as_str() }),
                &job.model,
            ),
            DlStatus::Failed(e) => handle.fail(&anyhow::anyhow!("{e}")),
            DlStatus::Cancelled => handle
                .fail_with_detail(serde_json::json!({ "cancelled": true, "model": job.model })),
            DlStatus::Downloading => {}
        }
    }

    // ── test seams ──────────────────────────────────────────────────────────
    /// Set the selected device (test seam).
    pub fn set_device_for_test(&mut self, device: Device) {
        self.device = device;
    }
    /// Start a download for catalog `idx` (test seam).
    pub fn download_for_test(&mut self, idx: usize) -> String {
        self.download(idx)
    }
    /// Poll in-flight downloads (test seam — the robot pumps this between frames).
    pub fn poll_for_test(&mut self) {
        self.poll();
    }
    /// Cancel a download by id (test seam).
    pub fn cancel_for_test(&mut self, job_id: &str) -> bool {
        self.cancel(job_id)
    }

    /// 🧠 Models tab's `state_json` slice (LAW #6): the catalog, the selected
    /// device + which backends/GPU libs are present, and every download job with
    /// its live progress + status — enough for a robot to assert a download job was
    /// created and progressed via the fake, no pixels.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "dest": self.dest.display().to_string(),
            "device": self.device.as_str(),
            "selected": self.selected,
            "selected_model": CATALOG.get(self.selected).map(|s| s.id),
            "catalog": CATALOG.iter().map(|s| serde_json::json!({
                "id": s.id,
                "backend": s.backend.as_str(),
                "note": s.note,
            })).collect::<Vec<_>>(),
            "gpu": {
                "onnxruntime_present": self.gpu.onnxruntime,
                "cuda_driver": self.gpu.cuda_driver,
                "cuda_pinned": self.gpu.cuda_pinned,
                "rocm": self.gpu.rocm,
                "rocm_pinned": self.gpu.rocm_pinned,
                "gen_candle": self.gpu.gen_candle,
                "gen_ollama": self.gpu.gen_ollama,
                "embed_ort": self.gpu.embed_ort,
            },
            "job_count": self.jobs.len(),
            "active_downloads": self.jobs.iter().filter(|j| !j.status.is_terminal()).count(),
            "jobs": self.jobs.iter().map(|j| serde_json::json!({
                "job_id": j.job_id,
                "model": j.model,
                "backend": j.backend.as_str(),
                "device": j.device.as_str(),
                "status": j.status.as_str(),
                "progress": j.frac,
                "error": match &j.status { DlStatus::Failed(e) => Some(e.clone()), _ => None },
            })).collect::<Vec<_>>(),
            "palette": self.theme.name,
        })
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        self.poll();
        // Keep repainting while a download is in flight so progress advances.
        if self.jobs.iter().any(|j| !j.status.is_terminal()) {
            ui.ctx().request_repaint();
        }
        let theme = self.theme;

        ui.horizontal(|ui| {
            ui.heading("🧠 Models");
        });
        ui.label(
            "browse known LLM / embedding models, pick a device, and download one \
             as a nornir job (progress + cancel below).",
        );

        // ── Active backend + GPU-lib probe banner ───────────────────────────────
        let mut backends = Vec::new();
        if self.gpu.gen_candle {
            backends.push("candle");
        }
        if self.gpu.gen_ollama {
            backends.push("ollama");
        }
        if self.gpu.embed_ort {
            backends.push("ort");
        }
        ui.horizontal_wrapped(|ui| {
            ui.colored_label(theme.text_dim, "backends:");
            ui.label(if backends.is_empty() {
                "none".into()
            } else {
                backends.join(" · ")
            });
            ui.separator();
            let (ort_col, ort_txt) = if self.gpu.onnxruntime {
                (theme.accent, "libonnxruntime: present")
            } else {
                (theme.text_dim, "libonnxruntime: not found")
            };
            ui.colored_label(ort_col, ort_txt);
            if self.gpu.cuda_driver {
                ui.colored_label(theme.accent, "· CUDA driver");
            }
            if self.gpu.rocm {
                ui.colored_label(theme.accent, "· ROCm");
            }
        });

        // ── Device selector ─────────────────────────────────────────────────────
        ui.horizontal(|ui| {
            ui.colored_label(theme.text_dim, "device:");
            for dev in Device::ALL {
                let avail = match dev {
                    Device::Cpu => true,
                    Device::Cuda => self.gpu.cuda_driver || self.gpu.cuda_pinned,
                    Device::Rocm => self.gpu.rocm || self.gpu.rocm_pinned,
                };
                let mut label = dev.label().to_string();
                if !avail && dev != Device::Cpu {
                    label.push_str(" (not detected)");
                }
                ui.selectable_value(&mut self.device, dev, label);
            }
        });
        ui.small(format!("models dir: {}", self.dest.display()));
        ui.separator();

        // ── Catalog ─────────────────────────────────────────────────────────────
        egui::Grid::new("models_catalog_grid")
            .striped(true)
            .num_columns(3)
            .spacing([18.0, 6.0])
            .show(ui, |ui| {
                ui.strong("model");
                ui.strong("backend");
                ui.strong("");
                ui.end_row();
                for (idx, spec) in CATALOG.iter().enumerate() {
                    ui.vertical(|ui| {
                        if ui.selectable_label(self.selected == idx, spec.id).clicked() {
                            self.selected = idx;
                        }
                        ui.small(spec.note);
                    });
                    ui.label(spec.backend.label());
                    if ui.button(format!("⬇ Download {}", spec.id)).clicked() {
                        self.pending_download.borrow_mut().push(idx);
                    }
                    ui.end_row();
                }
            });

        // ── Download jobs (progress + cancel) ───────────────────────────────────
        if !self.jobs.is_empty() {
            ui.separator();
            ui.strong("downloads");
            for job in &self.jobs {
                ui.horizontal(|ui| {
                    let frac = job.frac.unwrap_or(0.0);
                    ui.add(
                        egui::ProgressBar::new(frac)
                            .desired_width(200.0)
                            .text(format!("{} · {}", job.model, job.status.as_str())),
                    );
                    match &job.status {
                        DlStatus::Downloading => {
                            if ui.button("✖ Cancel").clicked() {
                                self.pending_cancel.borrow_mut().push(job.job_id.clone());
                            }
                        }
                        DlStatus::Failed(e) => {
                            ui.colored_label(RED, e);
                        }
                        DlStatus::Cancelled => {
                            ui.colored_label(AMBER, "cancelled");
                        }
                        DlStatus::Done => {
                            ui.colored_label(theme.accent, "✓");
                        }
                    }
                });
            }
        }

        // ── Drain the pure-view effects (the ONE live mutation path) ────────────
        let downloads: Vec<usize> = self.pending_download.borrow_mut().drain(..).collect();
        for idx in downloads {
            self.download(idx);
        }
        let cancels: Vec<String> = self.pending_cancel.borrow_mut().drain(..).collect();
        for id in cancels {
            self.cancel(&id);
        }
    }
}

/// A `/home`-anchored models directory (never the flaky source disk): honours
/// `NORNIR_MODELS_DIR`, else `$HOME/.nornir/models`, else `/tmp`.
fn default_models_dir() -> PathBuf {
    if let Ok(d) = std::env::var("NORNIR_MODELS_DIR") {
        return PathBuf::from(d);
    }
    if let Ok(home) = std::env::var("HOME") {
        return PathBuf::from(home).join(".nornir").join("models");
    }
    std::env::temp_dir().join("nornir-models")
}