nornir 0.4.27

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Launcher for the Urðr Threads visualizer.
//!
//! Usage (embedded — opens the warehouse directly):
//!     cargo run --release --features viz --bin urdr-threads \
//!         -- --warehouse /path/to/.nornir/warehouse --workspace my-ws
//!
//! Usage (thin client — reads from a running nornir-server over gRPC):
//!     NORNIR_SERVER=http://127.0.0.1:7878 NORNIR_SERVER_TOKEN=… \
//!     cargo run --release --features viz --bin urdr-threads -- --workspace my-ws
//! `NORNIR_WORKSPACE`, if set, selects the served workspace via the gRPC header.

use std::path::PathBuf;

use anyhow::Result;
use clap::Parser;

use nornir::viz::UrdrThreadsApp;

#[derive(Parser)]
#[command(name = "urdr-threads")]
#[command(about = "Time-travel visualizer for the Urðr warehouse", long_about = None)]
struct Cli {
    /// Path to the Iceberg warehouse root directory.
    /// Defaults to `~/.nornir/warehouse` (the home-derived warehouse, LAW 0:
    /// never /tmp). `nornir viz` passes `--warehouse` from the discovered config
    /// in fat mode so this default is only reached in corner cases.
    #[arg(long)]
    warehouse: Option<PathBuf>,

    /// Workspace name (matches the `[workspace] name =` from the descriptor).
    /// Empty (the default) = remote mode auto-selects the server's first
    /// registered workspace; local mode browses the warehouse's default.
    #[arg(long, default_value = "")]
    workspace: String,

    /// Optional path to `nornir.toml`. If omitted we try to discover one
    /// from the current directory; if that also fails the Knowledge tab
    /// just renders with no repos.
    #[arg(long)]
    config: Option<PathBuf>,

    /// Initial facett palette/theme (CLI parity with the 🎨 top-bar picker, LAW:
    /// never GUI-only). One of: default, sci-fi, nordic-aurora, cyberpunk-neon,
    /// amber-crt, deep-space, hugin-noir (`-`/`_`/space interchange,
    /// case-insensitive). `$NORNIR_VIZ_THEME` overrides this flag. An unknown
    /// name is ignored (falls back to `default`).
    #[arg(long)]
    theme: Option<String>,
    /// **LAW4b** — take a self-screenshot once the viz starts and write it to
    /// this path as a PNG (also appended to the warehouse `robot_test_results`
    /// blob store). Uses `ViewportCommand::Screenshot` (never compositor grabs).
    /// CLI parity with the Ctrl+Shift+S keyboard shortcut.
    #[arg(long)]
    screenshot: Option<String>,
}

/// Derive the server workspace name from a `workspace_<name>` path component
/// (the workspace-dir convention): `workspace_njord/release/nornir.toml` →
/// `Some("njord")`. Returns the first such component, or `None` if the path has
/// none.
fn workspace_from_path(p: &std::path::Path) -> Option<String> {
    p.components()
        .filter_map(|c| c.as_os_str().to_str())
        .find_map(|s| s.strip_prefix("workspace_").map(str::to_string))
        .filter(|s| !s.is_empty())
}

/// A viz crash must be self-documenting — the operator (or an agent) should read
/// WHAT crashed from the logs, never have to reproduce-and-ask. This panic hook
/// appends the panic message + location + the **tail of the action-log** (what was
/// clicked) + a backtrace to a durable crash file (`$NORNIR_VIZ_CRASH`, default
/// `<action-log>.crash`). Append-only + separate from the action-log, so a relaunch
/// never clobbers a prior crash record. Chains to the default hook (stderr too).
fn install_crash_hook() {
    let default_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let actionlog = std::env::var("NORNIR_VIZ_ACTIONLOG")
            .unwrap_or_else(|_| "/tmp/nornir_viz_actions.log".to_string());
        let crash_path = std::env::var("NORNIR_VIZ_CRASH")
            .unwrap_or_else(|_| format!("{actionlog}.crash"));
        let loc = info
            .location()
            .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
            .unwrap_or_else(|| "<unknown>".to_string());
        let msg = info
            .payload()
            .downcast_ref::<&str>()
            .map(|s| (*s).to_string())
            .or_else(|| info.payload().downcast_ref::<String>().cloned())
            .unwrap_or_else(|| "<non-string panic payload>".to_string());
        // The action-log tail = exactly "what the user was clicking" before the crash.
        let trail = std::fs::read_to_string(&actionlog)
            .ok()
            .map(|s| {
                let lines: Vec<&str> = s.lines().collect();
                lines[lines.len().saturating_sub(30)..].join("\n")
            })
            .unwrap_or_default();
        let bt = std::backtrace::Backtrace::force_capture();
        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&crash_path) {
            use std::io::Write;
            let _ = writeln!(
                f,
                "\n===== nornir-viz PANIC =====\nat {loc}\n{msg}\n--- last actions (what was clicked) ---\n{trail}\n--- backtrace ---\n{bt}\n============================",
            );
        }
        eprintln!("nornir-viz PANIC at {loc}: {msg}  (crash log → {crash_path})");
        default_hook(info);
    }));
}

fn main() -> Result<()> {
    install_crash_hook();
    let cli = Cli::parse();

    let loaded = match cli.config.as_deref() {
        Some(p) => nornir::config::load_explicit(p).ok(),
        None => std::env::current_dir()
            .ok()
            .and_then(|cwd| nornir::config::discover(&cwd).ok()),
    };
    let (workspace_root, repos, config_workspace) = match loaded {
        Some(loaded) => {
            // Derive the server workspace this config targets from its
            // `workspace_<name>` path component (the dir convention:
            // `workspace_njord/release/nornir.toml` → `njord`, matching the name
            // the server registers). Used to auto-select the right workspace in
            // remote mode instead of the alphabetically-first one. Try the
            // config path first, then the storage `local_path`.
            let config_ws = workspace_from_path(&loaded.config_path)
                .or_else(|| workspace_from_path(std::path::Path::new(&loaded.nornir.storage.local_path)));
            (
                loaded.workspace_root,
                loaded.nornir.repo.keys().cloned().collect(),
                config_ws,
            )
        }
        None => (PathBuf::new(), Vec::new(), None),
    };

    let native_options = eframe::NativeOptions {
        viewport: eframe::egui::ViewportBuilder::default()
            .with_inner_size([1200.0, 700.0])
            .with_title(format!(
                "Urðr Threads — nornir viz {}",
                nornir::viz::client_build()
            )),
        ..Default::default()
    };
    // Thin-client mode: `NORNIR_SERVER` set → read the timeline from a running
    // nornir-server over gRPC; otherwise open the local warehouse directly.
    let server = std::env::var("NORNIR_SERVER").ok().filter(|s| !s.is_empty());
    // `NORNIR_WORKSPACE` (the same selector the CLI/MCP use) overrides --workspace
    // so the viz targets a chosen served workspace (e.g. a monitored one). It is
    // also sent as the `nornir-workspace` gRPC header (see viz::remote).
    let workspace = std::env::var("NORNIR_WORKSPACE")
        .ok()
        .filter(|s| !s.is_empty())
        .unwrap_or(cli.workspace);

    // 🎨 Initial palette — CLI parity with the top-bar picker (LAW: never
    // GUI-only). `$NORNIR_VIZ_THEME` (same selector style as NORNIR_WORKSPACE)
    // overrides the `--theme` flag; an unknown / absent name leaves the default.
    let theme_env = std::env::var("NORNIR_VIZ_THEME").ok();
    let initial_palette =
        nornir::viz::facett_theme::Theme::resolve_initial(theme_env.as_deref(), cli.theme.as_deref());
    if let Some(req) = theme_env.as_deref().filter(|s| !s.is_empty()).or(cli.theme.as_deref()) {
        if nornir::viz::facett_theme::Theme::by_name(req).is_none() {
            eprintln!(
                "nornir-viz: unknown theme {req:?} — using `default`. Choices: {:?}",
                nornir::viz::facett_theme::Theme::names(),
            );
        } else {
            eprintln!("nornir-viz: initial palette → {}", initial_palette.name);
        }
    }

    // **LAW4b** — `--screenshot <path>` CLI parity: write a VizCommand::Screenshot
    // to the control channel file BEFORE the eframe loop starts, so the app picks
    // it up on the very first frame and fires ViewportCommand::Screenshot.
    // The app's control-channel poll runs at the top of draw_ui, so the command
    // arrives in the first frame and the screenshot is produced on the next one.
    let screenshot_path = cli.screenshot.clone();
    if let Some(ref path) = screenshot_path {
        let cmd = nornir::viz::control::VizCommand {
            screenshot: Some(nornir::viz::control::ScreenshotRequest {
                out_path: Some(path.clone()),
            }),
            ..Default::default()
        };
        if let Err(e) = nornir::viz::control::write_command(&cmd) {
            eprintln!("nornir-viz: --screenshot: could not write control command: {e}");
        } else {
            eprintln!("nornir-viz: --screenshot → will write PNG to {path}");
        }
    }

    eframe::run_native(
        "Urðr Threads",
        native_options,
        Box::new(move |cc| {
            // Force dark mode: the timeline/graphs are painted on a dark canvas,
            // but egui otherwise follows the system theme (light on some displays)
            // → dark/black UI text in the side panel + labels. Pin it dark.
            cc.egui_ctx.set_visuals(eframe::egui::Visuals::dark());
            let mut app = match server {
                Some(endpoint) => {
                    let token = std::env::var("NORNIR_SERVER_TOKEN").unwrap_or_default();
                    // `config_workspace` only takes effect when `workspace` is
                    // empty (no explicit --workspace/NORNIR_WORKSPACE) and the
                    // server registers it — see with_remote_preferring.
                    UrdrThreadsApp::with_remote_preferring(
                        endpoint, token, workspace, config_workspace, workspace_root, repos,
                    )
                }
                None => {
                    // Resolve the warehouse: --warehouse flag > home-derived default.
                    // Never use /tmp (LAW 0): fall back to ~/.nornir/warehouse so that
                    // EmptyLocal mode (no server, no config) still opens in the right place.
                    let wh = cli.warehouse.unwrap_or_else(nornir::config::warehouse_default_root);
                    UrdrThreadsApp::with_repos(wh, workspace, workspace_root, repos)
                }
            };
            // Apply the CLI/env-chosen palette before the first frame, so a
            // headless/CLI launch opens already re-skinned (GUI/CLI parity).
            // `set_palette` is a no-op when it equals the default already.
            app.set_palette(initial_palette);
            Ok(Box::new(app))
        }),
    )
    .map_err(|e| anyhow::anyhow!("eframe error: {e}"))?;
    Ok(())
}