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

nordisk/nornir

src/bin/urdr-threads.rs

//! Launcher for the **Urðr Threads** visualizer — the source behind TWO declared
//! atoms (`.nornir/build-variants.toml`), distinguished only by the `solo` feature:
//!
//! * **`nornir-ui`** (`required-features = ["viz"]`) — the GUI client. Renamed from
//!   the `nornir-ui` bin on 2026-08-01 for the uniform launch name the rest of
//!   the constellation already uses (`korp-ui`, `dwarves-ui`). Only the LAUNCHABLE
//!   was renamed: the product is still called Urðr Threads, the window title is
//!   unchanged, and this file keeps its name (the same call korp made when it
//!   renamed `korp` → `korp-ui` off `src/main.rs`).
//! * **`nornir-solo`** (`required-features = ["solo"]`, `solo = ["server", "viz"]`)
//!   — the APPLIANCE: the identical UI with `nornir::server_grpc` running inside
//!   the same process. See [`start_embedded_server`].
//!
//! Usage (embedded warehouse — opens it directly):
//!     cargo run --release --features viz --bin nornir-ui \
//!         -- --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 nornir-ui -- --workspace my-ws
//! `NORNIR_WORKSPACE`, if set, selects the served workspace via the gRPC header.
//!
//! Usage (solo — UI + server in one process, nothing else to install):
//!     cargo run --release --features solo --bin nornir-solo

use std::path::PathBuf;

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

use nornir::viz::UrdrThreadsApp;
use nornir::viz::facett_theme::ThemeExt; // `Theme::resolve_initial` (C8 CLI parity)

#[derive(Parser)]
#[command(name = "nornir-ui")]
// Build-version LAW: embed the pure-Rust git-hash identity in `--version`
// (`nornir::VERSION_LINE` = `v<pkg> (<git-sha>)`, stamped by build.rs on every
// build) PLUS `nornir::atom_line(env!("CARGO_BIN_NAME"))` — which atom this build
// is, named from the BIN (cargo unifies features across a package's bins, so a
// `cfg!` cannot tell these two apart; see `nornir::atom_line`). Same source the
// `nornir` CLI and `nornir-server` use (one writer, no drift), and it is what makes
// `nornir-solo --version` and `nornir-ui --version` print different text off one
// source file. See `nornir::version_full`.
#[command(version = nornir::version_full(env!("CARGO_BIN_NAME")))]
#[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>,
    /// 🎬 **Demo mode** — launch a hands-free guided tour that ROBOT-DRIVES the
    /// viz through every tab (Arch/Metro, Knowledge, Funnel, Test, Warehouse Deck,
    /// Release, Security) via the existing control channel, with pauses so the
    /// motion (graph pan/zoom, 3D spin, palette glow) plays. Loops until closed.
    /// CLI parity: `nornir viz --demo`. `$NORNIR_VIZ_DEMO_BEAT_MS` overrides the
    /// pause between beats (default 2200ms).
    #[arg(long)]
    demo: bool,
    /// 🤖 **robot-remote** — MOUNT this viz process as a WIRE-REMOTE robot-drive
    /// target on the given Unix socket (the full-system-autotest fleet dials
    /// `<dir>/nornir.sock`) and serve drivers single-threaded/blocking INSTEAD of
    /// opening the GUI. Requires the `robot-remote` feature; OFF by default so a
    /// shipped viz never mounts a socket. CLI parity — never GUI-only.
    #[arg(long)]
    robot_remote: 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);
    }));
}

/// ⭐ **`nornir-solo` — the whole product in ONE process.**
///
/// Boots `nornir::server_grpc` on a background tokio runtime thread, waits until
/// the bind address really ACCEPTS a TCP connection, and prints the one
/// machine-checkable marker
///
/// ```text
/// NORNIR-SOLO: gRPC listening on 127.0.0.1:7878
/// ```
///
/// before the caller opens the window. `tests/solo_embeds_server.rs` runs the built
/// binary, waits for that line, and then makes a REAL `Health.Ping` gRPC call
/// against the named port — with the identical probe against `nornir-ui` (same
/// source, no `solo` feature) required to find NOTHING listening. That pair is what
/// keeps this from being a declaration that embeds nothing.
///
/// # Three things this deliberately does NOT do
///
/// * **It does not re-implement `nornir-server`.** The entire body below the
///   runtime spawn is one call to
///   [`nornir::server_grpc::run_with_token`](nornir::server_grpc::run_with_token) —
///   the same shared entrypoint `src/bin/nornir-server.rs` and `dwarves-full` reach
///   (LAW 5). Token and address come from `server_grpc::{resolve_bearer, bind_addr}`,
///   the one reader of each, so a solo appliance and a standalone daemon can never
///   disagree about what `NORNIR_SERVER_ADDR` means.
///
/// * **It does not open the workspace registry itself.** `run_with_policy` opens
///   the redb registry as its SINGLE WRITER; a co-tenant in the same process that
///   opened it separately would deadlock on the exclusive lock. The viz never
///   touches `nornir::registry` (measured 2026-08-01: the only non-test callers of
///   `Registry::open` are `server_grpc.rs` and the `nornir` CLI), so the invariant
///   holds by construction rather than by comment.
///
/// * **It does not read `--token` / `--addr` off argv.** It cannot: clap owns this
///   process's argv and rejects unknown flags before `main` gets here, so the
///   argv-scanning inside `server_grpc` can only ever fall through to the env. Env
///   is therefore the solo configuration surface — `NORNIR_SERVER_ADDR`,
///   `NORNIR_SERVER_TOKEN`.
///
/// A fresh box has no `~/.nornir/token`. `nornir-server` MUST refuse to boot in
/// that state; a solo appliance whose whole promise is "nothing else to install"
/// must not, so it falls back to an OPEN listener on the resolved (loopback by
/// default) address and says so on stderr. That divergence is the reason
/// `resolve_bearer` returns `Option` instead of bailing.
#[cfg(feature = "solo")]
fn start_embedded_server() -> Result<()> {
    use std::net::TcpStream;
    use std::time::{Duration, Instant};

    let addr = nornir::server_grpc::bind_addr()?;
    let token = match nornir::server_grpc::resolve_bearer() {
        Ok(Some(t)) => t,
        Ok(None) => {
            eprintln!(
                "nornir-solo: no bearer configured (no --token, no NORNIR_SERVER_TOKEN, no \
                 {}) — the embedded server listens OPEN on {addr}. Write a ≥16-char token \
                 there (or run `nornir install`) to lock it down.",
                nornir::config::nornir_home().join("token").display(),
            );
            String::new()
        }
        Err(e) => return Err(e),
    };

    // The UI half becomes a plain in-process CLIENT of the server half — which is
    // also what keeps the single-writer promise: only `server_grpc` opens the
    // warehouse and the registry, and every tab reads them back over the wire.
    let ui_token = token.clone();

    // A dedicated OS thread with its own multi-thread runtime: the GUI owns the
    // main thread (eframe::run_native never returns), so the server cannot share it.
    std::thread::Builder::new()
        .name("nornir-solo-grpc".to_string())
        .spawn(move || {
            let rt = match tokio::runtime::Builder::new_multi_thread()
                .enable_all()
                .build()
            {
                Ok(rt) => rt,
                Err(e) => {
                    eprintln!("nornir-solo: could not build the server runtime: {e}");
                    return;
                }
            };
            if let Err(e) = rt.block_on(nornir::server_grpc::run_with_token(std::sync::Arc::new(
                token,
            ))) {
                eprintln!("nornir-solo: embedded gRPC server stopped: {e:#}");
            }
        })
        .map_err(|e| anyhow::anyhow!("spawn the nornir-solo server thread: {e}"))?;

    // APPLIED readiness, not a sleep and not an `Ok`: the marker is printed only
    // after a real TCP connection to the real bind address is accepted. If the
    // server never binds, this reports THAT — it does not print a listening line
    // over a dead port, which is precisely the vacuous pass a solo atom invites.
    let deadline = Instant::now() + Duration::from_secs(60);
    while Instant::now() < deadline {
        if TcpStream::connect_timeout(&addr, Duration::from_millis(250)).is_ok() {
            println!("NORNIR-SOLO: gRPC listening on {addr}");
            unsafe {
                std::env::set_var("NORNIR_SERVER", format!("http://{addr}"));
                if !ui_token.is_empty() {
                    std::env::set_var("NORNIR_SERVER_TOKEN", &ui_token);
                }
            }
            return Ok(());
        }
        std::thread::sleep(Duration::from_millis(100));
    }
    anyhow::bail!(
        "nornir-solo: the embedded gRPC server never accepted a connection on {addr} within \
         60s — refusing to open a UI that claims a server it does not have"
    )
}

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

    // ⭐ solo: bring the server up INSIDE this process before anything else, so
    // both the GUI path below and the headless `--robot-remote` path get it.
    //
    // ⚠ THE FEATURE ALONE CANNOT GATE THIS. `solo` is a PACKAGE feature and cargo
    // unifies features across every bin it builds in one invocation, so a
    // `--features solo` build of this source ALSO produces `nornir-ui` with this
    // block compiled in — and a bare `#[cfg(feature = "solo")]` would therefore
    // start a gRPC server inside the UI atom too. That is not a hypothetical: it is
    // precisely the run `tests/solo_embeds_server.rs` makes, so the anti-vacuous
    // anchor `the_ui_atom_serves_nothing` would have been measuring a UI binary that
    // really did serve. (korp hit the same wall from the other side — its `korp-ui`
    // grew from 56 MB to 264 MB when a `--features solo` test run rebuilt it with
    // the embed — and worked around it by running the negative leg as a SECOND
    // cargo invocation. This is the by-construction fix instead, and it is stronger:
    // the negative is proven in the SAME, hostile, solo-featured build.)
    //
    // `CARGO_BIN_NAME` is per-BIN and cargo cannot unify it. The appliance behaviour
    // belongs to the appliance binary and to nothing else. The feature is still
    // load-bearing: `[[bin]] nornir-solo` has `required-features = ["solo"]`, so
    // without it this binary does not exist at all.
    #[cfg(feature = "solo")]
    if env!("CARGO_BIN_NAME") == "nornir-solo" {
        start_embedded_server()?;
    }

    // 🤖 robot-remote: hand THIS process to the wire-remote mount (a Unix socket the
    // autotest fleet dials) instead of opening the GUI. Single-threaded blocking serve
    // (ROOT LAW #0). Off unless the `robot-remote` feature is built in.
    if let Some(sock) = cli.robot_remote.clone() {
        #[cfg(feature = "robot-remote")]
        {
            nornir::viz::robot_remote::serve_robot_remote(&sock)?;
            return Ok(());
        }
        #[cfg(not(feature = "robot-remote"))]
        {
            anyhow::bail!(
                "this build ({}) has no `robot-remote` feature; rebuild with \
                 `--features robot-remote` to mount a robot-drive socket at {}",
                nornir::atom_line(env!("CARGO_BIN_NAME")),
                sock.display(),
            );
        }
    }

    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 {
        // LAW #4 — WebGPU, never OpenGL. Named explicitly rather than left to
        // `Renderer::default()`, which follows whichever backend feature the manifest
        // carries: this viz rendered OpenGL for months with nothing in the source saying
        // so. It also matters for what the viz DRAWS — the facett facets it embeds
        // (graph3d, helix, warehousedeck, graphview, …) carry wgpu custom passes that only
        // paint natively under wgpu and silently degrade to the CPU painter under glow.
        renderer: eframe::Renderer::Wgpu,
        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);
            // 🎬 Demo mode: arm the hands-free guided tour. It drives the same
            // control channel a human/MCP click uses, so the motion plays.
            if cli.demo {
                let beat_ms = std::env::var("NORNIR_VIZ_DEMO_BEAT_MS")
                    .ok()
                    .and_then(|s| s.parse::<u64>().ok())
                    .unwrap_or(2200);
                app.enable_demo(beat_ms);
            }
            Ok(Box::new(app))
        }),
    )
    .map_err(|e| anyhow::anyhow!("eframe error: {e}"))?;
    Ok(())
}