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

nordisk/dwarves

dwarves-gui/src/robot_remote.rs

//! **`robot-remote`** — MOUNT dwarves as a WIRE-REMOTE robot-drive target.
//!
//! This closes the North-Star autotest "last mile" for the FIRST real app: edda's
//! [`fleet`] harness can already drive an egui member's mounted
//! [`CmdJsonServer`](nornir_robotui::cmd_json::CmdJsonServer) over a byte stream (the
//! fat leg, `EguiWire`), but the honest gap was per-app — "each app must MOUNT a
//! `CmdJsonServer` over its egui UI on a socket". dwarves-gui 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 demo's OWN `egui_kittest` shell (the SAME
//!   harness shape the wizard robot test in `main.rs` builds — REUSE, not a twin), and
//!   the app's observable state IS `Facet::state_json` (no twinned oracle).
//! * The transport is an [`IoChannel`] over a real `AF_UNIX` [`UnixListener`] — the
//!   Unix twin of `fleet`'s `TcpEguiWire`; edda's reusable `UnixEguiWire` dials the
//!   exact socket [`serve_robot_remote`] binds.
//!
//! ## 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 `--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::RobotSession;

// The shell, its draw closure AND the sized off-screen surface are shared with the other
// drives (`crate::robot_mode` headless, `crate::robot_watch` visible) so every mode drives
// the SAME `ShellApp` identically.
use crate::robot_shell::{build_shell_session, ShellApp};

/// Build a [`CmdJsonServer`] over a [`RobotSession`] driving the WHOLE dwarves shell
/// through its real `egui_kittest` harness — the host side a driver dials. Offline-
/// safe construction (a dead forge endpoint, the FAT/embedded funnel, an out-of-repo
/// ledger root) — the SAME headless build the in-crate robot tests use, so mounting
/// the server dials no network.
fn robot_session() -> CmdJsonServer<RobotSession<'static, ShellApp>> {
    // The offline-safe shell on the sized, settled off-screen surface — both owned by
    // `robot_shell` (the load-bearing 3000pt width and WHY it is load-bearing are
    // documented on `ROBOT_SURFACE`) so this mount and the `--robot-mode headless` tour
    // drive ONE harness shape and cannot drift apart on the one number that decides
    // whether the last tabs are clickable at all.
    CmdJsonServer::new(build_shell_session("dwarves-gui"))
}

/// MOUNT the `CmdJsonServer` on a Unix socket at `path` and serve drivers on THIS (the
/// app'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!(
        "dwarves-gui: 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!("dwarves-gui: robot-remote connection ended: {e}");
                }
            }
            Err(e) => eprintln!("dwarves-gui: robot-remote stream clone failed: {e}"),
        }
    }
}

// ── the REAL-SOCKET proof — the first real app 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 dwarves egui 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_dwarves_shell_over_a_real_unix_socket() {
        let dir = tempfile::tempdir().unwrap();
        let sock = dir.path().join("dwarves-robot.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 shipped shell opens on the Lenses tab (the Mímir-family landing).
        match call!(CmdRequest::ReadStateAt {
            pointer: "/active_tab".into()
        }) {
            CmdResponse::StateAt { value } => {
                assert_eq!(value, Some(json!("Lenses")), "remote shell opens on Lenses")
            }
            other => panic!("want StateAt, got {other:?}"),
        }

        // locate-by-testid: the Pipeline tab resolves to a real node id remotely.
        match call!(CmdRequest::Locate {
            target: Target::label("Pipeline")
        }) {
            CmdResponse::Located { id } => {
                assert!(id.is_some(), "the Pipeline tab is located over the wire")
            }
            other => panic!("want Located, got {other:?}"),
        }

        // Drive a REAL tab-switch CLICK over the socket.
        match call!(CmdRequest::Act {
            action: Action::ClickLabel("Pipeline".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 dwarves UI actually changed.
        match call!(CmdRequest::ReadStateAt {
            pointer: "/active_tab".into()
        }) {
            CmdResponse::StateAt { value } => assert_eq!(
                value,
                Some(json!("Pipeline")),
                "the click moved the REMOTE dwarves 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: "/active_tab".into(),
                expected: json!("Warehouse")
            },
        }) {
            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: "/active_tab".into(),
                expected: json!("Pipeline")
            },
        }) {
            CmdResponse::Asserted {
                pass: true,
                reason: None,
            } => {}
            other => panic!("the true state must pass over the wire: {other:?}"),
        }

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "wire_remote_socket_drivable",
            true,
            "dwarves-gui MOUNTS a cmd_json::CmdJsonServer over its egui shell on a real \
             AF_UNIX socket; a CmdJsonClient pings, locates + CLICKS the Pipeline tab, and \
             the wired-back state_json shows the REMOTE active tab changed Warehouse→Pipeline \
             — the first real app driven wire-remote end-to-end (red-when-broken)",
        );
    }

    /// The per-tab SCENARIO LIBRARY proof: EVERY recorded
    /// [`Scenario`](nornir_robotui::scenario::Scenario) from
    /// [`crate::scenarios::dwarves_tab_scenarios`] is driven, step by step, against the
    /// LIVE mounted dwarves shell OVER A REAL UNIX SOCKET (the same bind→connect→accept
    /// →lockstep-`serve_one` pattern the mount test above uses — single-threaded, ROOT
    /// LAW #0). Each step is a wire [`CmdRequest::DriveStep`] (act THEN assert), and we
    /// assert every step PASSES on the live shell.
    ///
    /// RED-WHEN-BROKEN: a broken scenario (a wrong `ClickLabel`, a stale `state_json`
    /// oracle, a removed/renamed tab, a swallowed click) fails its step's assertion, so
    /// its `Stepped { pass: false, message }` collects into `failures` and the final
    /// assert goes red with the exact tab+step+reason. This proves the scenarios drive
    /// REAL dwarves, not a mock.
    #[test]
    fn drives_every_recorded_tab_scenario_over_a_real_unix_socket() {
        use crate::scenarios::{dwarves_tab_scenario_library, dwarves_tab_scenarios};

        let dir = tempfile::tempdir().unwrap();
        let sock = dir.path().join("dwarves-tab-scenarios.sock");

        let listener = UnixListener::bind(&sock).unwrap();
        let client_stream = UnixStream::connect(&sock).unwrap();
        let (server_stream, _) = listener.accept().unwrap();

        // Build the offline shell under a HERMETIC env: hold `ENV_LOCK` and point TMPDIR +
        // the constellation root at a SEPARATE empty tempdir (kept apart from the socket
        // dir above, which must stay untouched) for exactly the app-build window. Without
        // this the Release tab resolves the live `constellation_root()` (the real dev-box
        // constellation) and reads `std::env::temp_dir()` for its convergence file, so a
        // concurrent Release `scratch_tmp` test (which repoints TMPDIR at a scratch dir
        // holding a SEEDED 1-node convergence) races in and the recorded scenario flaps.
        // With TMPDIR isolated to an empty dir the tab deterministically reads NOTHING and
        // says so (its named empty state), like every other offline tab. The Build Thing
        // scene resolves ITS root from `$DWARVES_CONSTELLATION` (else the directories above
        // the cwd), so that is pointed at the same empty dir: without it the scene finds
        // whichever sibling checkouts this machine holds, and the scenario's "the empty
        // root refuses" steps test the machine. The env is read only while the panes are
        // constructed, so the lock is released — and env restored — the moment the shell
        // is built, before any socket I/O.
        let mut server = {
            let _env = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
            let env_dir = tempfile::tempdir().unwrap();
            let prev_tmp = std::env::var("TMPDIR").ok();
            let prev_root = std::env::var("DWARVES_CONSTELLATION_ROOT").ok();
            let prev_builds = std::env::var("DWARVES_CONSTELLATION").ok();
            // SAFETY: single-threaded build window holding ENV_LOCK — no other lock-holding
            // test reads env concurrently (the discipline `release.rs`'s `scratch_tmp` uses).
            unsafe {
                std::env::set_var("TMPDIR", env_dir.path());
                std::env::set_var("DWARVES_CONSTELLATION_ROOT", env_dir.path());
                std::env::set_var("DWARVES_CONSTELLATION", env_dir.path());
            }
            let session = robot_session();
            unsafe {
                match &prev_tmp {
                    Some(v) => std::env::set_var("TMPDIR", v),
                    None => std::env::remove_var("TMPDIR"),
                }
                match &prev_root {
                    Some(v) => std::env::set_var("DWARVES_CONSTELLATION_ROOT", v),
                    None => std::env::remove_var("DWARVES_CONSTELLATION_ROOT"),
                }
                match &prev_builds {
                    Some(v) => std::env::set_var("DWARVES_CONSTELLATION", v),
                    None => std::env::remove_var("DWARVES_CONSTELLATION"),
                }
            }
            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,
        ));

        // Lockstep pump (stands in for the host's own I/O task without a 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 first — the socket is live.
        assert!(
            matches!(call!(CmdRequest::Ping), CmdResponse::Pong),
            "ping over the socket"
        );

        // The library is honest & complete: the recorded tab set MUST equal the shell's
        // OWN `/tabs` oracle (a removed/renamed/added tab here goes red).
        let lib = dwarves_tab_scenario_library();
        let recorded: Vec<String> = lib.iter().map(|t| t.tab.to_string()).collect();
        match call!(CmdRequest::ReadStateAt {
            pointer: "/tabs".into()
        }) {
            CmdResponse::StateAt { value: Some(tabs) } => {
                let live: Vec<String> =
                    serde_json::from_value(tabs).expect("/tabs is a string list");
                assert_eq!(
                    recorded, live,
                    "the recorded scenario library MUST cover exactly the shell's registered tabs, in order",
                );
            }
            other => panic!("want the shell's /tabs list, got {other:?}"),
        }

        // Drive EVERY step of EVERY scenario over the wire; collect any failure.
        let mut failures: Vec<String> = Vec::new();
        let mut steps_driven = 0usize;
        for scenario in dwarves_tab_scenarios() {
            let tab = scenario.suite.clone();
            for step in scenario.steps {
                let label = step.label.clone();
                match call!(CmdRequest::DriveStep { step }) {
                    CmdResponse::Stepped { pass: true, .. } => steps_driven += 1,
                    CmdResponse::Stepped {
                        pass: false,
                        message,
                    } => {
                        failures.push(format!("[{tab}] {label}: {message}"));
                    }
                    other => failures.push(format!("[{tab}] {label}: unexpected {other:?}")),
                }
            }
            // Diagnostic: dump the live pane sub-oracle's top-level keys (helps keep the
            // recorded identity oracles honest against the real shell).
            let ptr = format!(
                "/{}",
                tab.to_ascii_lowercase()
                    .replace('~', "~0")
                    .replace('/', "~1")
            );
            if let CmdResponse::StateAt { value: Some(v) } = call!(CmdRequest::ReadStateAt {
                pointer: ptr.clone()
            }) {
                let keys: Vec<String> = v
                    .as_object()
                    .map(|o| o.keys().cloned().collect())
                    .unwrap_or_default();
                eprintln!("dwarves tab {tab:>10}  {ptr} keys = {keys:?}");
            }
        }

        assert!(
            failures.is_empty(),
            "every recorded tab scenario must PASS on the live shell — {} step(s) failed:\n  {}",
            failures.len(),
            failures.join("\n  "),
        );
        assert_eq!(
            steps_driven,
            dwarves_tab_scenarios()
                .iter()
                .map(|s| s.steps.len())
                .sum::<usize>(),
            "every recorded step was driven over the socket",
        );

        let real = lib.iter().filter(|t| !t.probe_only).count();
        let probe_only = lib.iter().filter(|t| t.probe_only).count();
        eprintln!(
            "dwarves tab-scenario library: {} tabs, {} with a real interaction, {} probe-only, {} steps driven",
            lib.len(), real, probe_only, steps_driven,
        );

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "per_tab_scenarios_drive_live_shell",
            failures.is_empty(),
            "the recorded per-tab robot SCENARIO LIBRARY (scenarios::dwarves_tab_scenarios) \
             drives EVERY dwarves viz tab against the LIVE mounted egui shell over a real \
             AF_UNIX socket (bind→connect→accept→lockstep serve_one; DriveStep per step): \
             each tab is SELECTED (ClickLabel → /active_tab moved), asserted to have DREW, \
             and asserted to fold its OWN state_json identity — the fleet's RobotDriveMember \
             now has a specific-tab script, not a bare liveness probe (red-when-broken)",
        );
    }
}