src/viz/robot_remote.rs
//! **`robot-remote`** — MOUNT nornir's viz as a WIRE-REMOTE robot-drive target.
//!
//! This closes the LAST mile of the North-Star full-system-autotest fleet: edda's
//! fleet harness already drives an egui member's mounted
//! [`CmdJsonServer`](nornir_robotui::cmd_json::CmdJsonServer) over a byte stream, and
//! four sibling apps (dwarves-demo, holger-ui, korp, ordning-gui) already MOUNT one.
//! nornir's viz — the fleet's 5th and final member, closing the last 28 `@fat` nodes
//! (one per [`Tab`](crate::viz::Tab)) — mounts it here.
//!
//! ## What this is (REUSE, no second drive path)
//! * The command vocabulary is the ONE robotui `Action`/`Assert`/`Target`/`Step` set
//! carried by `cmd_json` — the SAME a co-located [`RobotSession`] uses. Nothing new.
//! * The host is a [`RobotSession`] over the viz's OWN `egui_kittest` shell — the SAME
//! harness shape `tests/robot_viz.rs` builds (a one-line [`Observable`] delegate to
//! the viz's existing `state_json()`), REUSE not a twin. The observable oracle IS
//! [`UrdrThreadsApp::state_json`] (no twinned oracle).
//! * The transport is an [`IoChannel`] over a real `AF_UNIX` [`UnixListener`] — the
//! Unix twin of the fleet's TCP wire; edda's reusable `UnixEguiWire` dials the exact
//! socket [`serve_robot_remote`] binds (`<dir>/nornir.sock`).
//!
//! ## Concurrency (ROOT LAW #0)
//! The server is plain BLOCKING I/O pumped single-threaded — it spawns NOTHING. The
//! mount runs on the process's own thread (the CLI hands `main` over to it); a driver
//! is served sequentially via [`CmdJsonServer::serve`] until it hangs up (EOF).
//!
//! ## Scope
//! Feature-gated behind `robot-remote` and reached ONLY via the `nornir-ui`
//! / `nornir-solo` `--robot-remote <socket>` CLI flag, so the SHIPPED default binary never mounts an
//! always-on socket.
use std::io::{self, BufReader};
use std::os::unix::net::UnixListener;
use std::path::Path;
use nornir_robotui::cmd_json::{CmdJsonServer, IoChannel};
use nornir_robotui::egui::Vec2;
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{Observable, RobotSession};
use crate::viz::UrdrThreadsApp;
/// The viz shell wrapped as an [`Observable`] — the robot reads exactly what the
/// shell renders through the SAME `state_json()` the in-process reference test
/// (`tests/robot_viz.rs`) uses (no twinned oracle). The whole `UrdrThreadsApp` is
/// mounted, so a driver can read/act on every one of the 28 tabs, not one pane.
struct VizApp(UrdrThreadsApp);
impl Observable for VizApp {
fn state_json(&self) -> serde_json::Value {
self.0.state_json()
}
}
/// Build a [`CmdJsonServer`] over a [`RobotSession`] driving the WHOLE nornir viz
/// through its real `egui_kittest` harness — the host side a driver dials. Built
/// offline-safe (a throwaway warehouse root, no server endpoint, empty repo list) —
/// the SAME headless build the in-crate robot tests use, so mounting the server dials
/// no network.
fn robot_session() -> CmdJsonServer<RobotSession<'static, VizApp>> {
// Skip the durable action-log sink so its drain thread doesn't hold the catalog's
// exclusive lock for the app's lifetime (mirrors `tests/robot_viz.rs::robot`). We
// are the whole process here — a dedicated robot-drive mount — so setting a global
// is safe (single-threaded; no GUI thread races it).
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };
let warehouse_root =
std::env::temp_dir().join(format!("nornir-robot-remote-{}", std::process::id()));
let _ = std::fs::create_dir_all(&warehouse_root);
let mut app = UrdrThreadsApp::with_repos(
warehouse_root,
"nornir".to_string(),
std::path::PathBuf::new(),
vec!["nornir".to_string()],
);
// Age `started_at` past the opaque intro splash so the tab bar is hit-testable for
// a driver. No data is seeded: a robot-drive mount should serve the app as it
// really starts.
//
// ⚠ This line used to be `app.inject_test_results_for_test(Vec::new())`, called
// purely for the same splash side-effect. `ce553b9` ("viz: cut the
// test/bench/coverage/leaderboard DISPLAY tabs") removed that injector along with
// the 🧪 Test tab and left the call behind — a hard `E0599` that sat on `main`
// undetected because `robot-remote` is in no default feature set and no CI
// command built it, so the whole module had not been compiled since. Measured
// 2026-08-01 on `origin/main` (eab671a). It is now built by the
// `--features solo,robot-remote` CI step that `tests/solo_embeds_server.rs`
// requires.
app.age_past_splash_for_test();
// A generously sized canvas so the whole tab bar + the active pane lay out and are
// hit-testable (mirrors the `robot_viz` kittest harness).
let harness = Harness::builder()
.with_size(Vec2::new(1400.0, 900.0))
.build_ui_state(|ui, a: &mut VizApp| a.0.draw_ui(ui), VizApp(app));
let mut session = RobotSession::new(harness, "nornir");
session.run(4); // settle layout + first paint so every tab is in the AccessKit tree
CmdJsonServer::new(session)
}
/// MOUNT the `CmdJsonServer` on a Unix socket at `path` and serve drivers on THIS (the
/// process's own) blocking thread — NO `thread::spawn` (ROOT LAW #0). One driver at a
/// time, sequentially: the fleet opens a fresh `CmdJsonClient` per tab drive, so
/// [`serve`](CmdJsonServer::serve) blocks answering that driver until it hangs up
/// (EOF), then the next connection is accepted. A per-connection I/O error is logged,
/// not fatal. Never returns except on an `accept` error.
pub fn serve_robot_remote(path: &Path) -> io::Result<()> {
let _ = std::fs::remove_file(path); // clear a stale socket so the re-bind succeeds
let listener = UnixListener::bind(path)?;
let mut server = robot_session();
eprintln!(
"nornir viz: robot-remote server listening on {}",
path.display()
);
loop {
let (stream, _) = listener.accept()?;
match stream.try_clone() {
Ok(rd) => {
let mut chan = IoChannel::new(BufReader::new(rd), stream);
if let Err(e) = server.serve(&mut chan) {
eprintln!("nornir viz: robot-remote connection ended: {e}");
}
}
Err(e) => eprintln!("nornir viz: robot-remote stream clone failed: {e}"),
}
}
}
// ── the REAL-SOCKET proof — nornir's viz driven wire-remote end-to-end ────────────
#[cfg(test)]
mod tests {
use super::*;
use nornir_robotui::cmd_json::{CmdJsonClient, CmdRequest, CmdResponse};
use nornir_robotui::{Action, Assert, Target};
use serde_json::json;
use std::os::unix::net::UnixStream;
/// The whole point: a driver drives the REAL nornir viz shell OVER A REAL UNIX
/// SOCKET (no duplex, no container — an actual `AF_UNIX` stream). Single-threaded,
/// lockstep (ROOT LAW #0): connect into the listener backlog, accept, then pump the
/// host's `serve_one` between the client's `send` and `recv` — the app's own I/O
/// task without a thread.
///
/// RED-WHEN-BROKEN: a real tab-switch CLICK sent over the socket must move the
/// REMOTE shell's active tab and the wired-back `state_json` must reflect it; a
/// broken mount, a wrong socket, a dropped command, or a swallowed click all fail
/// this. A stale assertion must surface as `Asserted { pass: false }` with a reason.
#[test]
fn drives_the_nornir_viz_over_a_real_unix_socket() {
let dir = tempfile::tempdir().unwrap();
let sock = dir.path().join("nornir.sock");
// Bind → connect (into the backlog) → accept — all single-threaded, no spawn.
let listener = UnixListener::bind(&sock).unwrap();
let client_stream = UnixStream::connect(&sock).unwrap();
let (server_stream, _) = listener.accept().unwrap();
let mut server = robot_session();
let mut server_chan = IoChannel::new(
BufReader::new(server_stream.try_clone().unwrap()),
server_stream,
);
let mut client = CmdJsonClient::new(IoChannel::new(
BufReader::new(client_stream.try_clone().unwrap()),
client_stream,
));
// A lockstep pump: send → host serves one → recv (stands in for the host's own
// I/O task without any thread).
macro_rules! call {
($req:expr) => {{
client.send(&$req).expect("send over the socket");
assert!(
server.serve_one(&mut server_chan).expect("host serves one"),
"host served a request over the socket",
);
client.recv().expect("recv over the socket")
}};
}
// Handshake travels the real socket.
assert!(
matches!(call!(CmdRequest::Ping), CmdResponse::Pong),
"ping/pong over a real AF_UNIX socket",
);
// The mounted shell opens on the 🧪 Test tab (the splash-dismiss inject selects
// it) — read the REMOTE active tab back over the wire.
match call!(CmdRequest::ReadStateAt {
pointer: "/tab".into()
}) {
CmdResponse::StateAt { value } => {
assert_eq!(
value,
Some(json!("Test")),
"remote viz opens on the Test tab"
)
}
other => panic!("want StateAt, got {other:?}"),
}
// locate-by-testid: the 🧬 nornir tab resolves to a real node id remotely.
match call!(CmdRequest::Locate {
target: Target::label("🧬 nornir")
}) {
CmdResponse::Located { id } => {
assert!(id.is_some(), "the 🧬 nornir tab is located over the wire")
}
other => panic!("want Located, got {other:?}"),
}
// Drive a REAL tab-switch CLICK over the socket (🧪 Test → 🧬 nornir).
match call!(CmdRequest::Act {
action: Action::ClickLabel("🧬 nornir".into())
}) {
CmdResponse::Acted {
ok: true,
error: None,
} => {}
other => panic!("the tab click did not land over the wire: {other:?}"),
}
// The wired-back state proves the REMOTE nornir viz actually changed.
match call!(CmdRequest::ReadStateAt {
pointer: "/tab".into()
}) {
CmdResponse::StateAt { value } => assert_eq!(
value,
Some(json!("Nornir")),
"the click moved the REMOTE nornir active tab over the socket",
),
other => panic!("want StateAt, got {other:?}"),
}
// RED-WHEN-BROKEN: a stale assertion must surface as Asserted{pass:false} with a
// reason — not a silent pass, not a dropped connection.
match call!(CmdRequest::Assert {
assert: Assert::State {
pointer: "/tab".into(),
expected: json!("Test")
},
}) {
CmdResponse::Asserted {
pass: false,
reason: Some(r),
} => {
assert!(!r.is_empty(), "the failure reason travels the wire")
}
other => panic!("a stale assertion must fail over the wire: {other:?}"),
}
// …and the TRUE post-click state passes over the same wire.
match call!(CmdRequest::Assert {
assert: Assert::State {
pointer: "/tab".into(),
expected: json!("Nornir")
},
}) {
CmdResponse::Asserted {
pass: true,
reason: None,
} => {}
other => panic!("the true state must pass over the wire: {other:?}"),
}
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"nornir",
"wire_remote_socket_drivable",
true,
"nornir's viz MOUNTS a cmd_json::CmdJsonServer over its egui shell on a real \
AF_UNIX socket; a CmdJsonClient pings, locates + CLICKS the 🧬 nornir tab, and \
the wired-back state_json shows the REMOTE active tab changed Test→Nornir — the \
5th and final full-system-autotest fleet member driven wire-remote (red-when-broken)",
);
}
}