nornir 0.4.23

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.
    #[arg(long, default_value = "/tmp/nornir-demo-warehouse")]
    warehouse: 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>,
}

/// 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);

    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 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 => UrdrThreadsApp::with_repos(cli.warehouse, workspace, workspace_root, repos),
            };
            Ok(Box::new(app))
        }),
    )
    .map_err(|e| anyhow::anyhow!("eframe error: {e}"))?;
    Ok(())
}