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(())
}