Gunnar

nordisk/holger-egui

src/robot_remote.rs

//! **`robot-remote`** — MOUNT holger-server-ui as a WIRE-REMOTE robot-drive target.
//!
//! This flips holger from a probe-only fleet member to a really-driven one: 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, `UnixEguiWire`), but the honest gap was per-app — "each app must MOUNT a
//! `CmdJsonServer` over its egui UI on a socket". holger-server-ui mounts it here, exactly the
//! way dwarves-demo did (`robot_remote.rs` + `serve_robot_remote`).
//!
//! ## 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 the co-located kittest robot suite
//!   (`tests/robot_ui_app.rs`) drives through a [`RobotSession`]. Nothing new.
//! * The host is a [`RobotSession`] over holger-server-ui's OWN `egui_kittest` shell (the SAME
//!   `Harness::build_state(|ctx, a| a.draw_ui(ui), …)` shape the robot tests build —
//!   REUSE, not a twin), and the app's observable state IS
//!   [`HolgerUiApp::state_json`](crate::app::HolgerUiApp::state_json) (no twinned oracle).
//! * The transport is an [`IoChannel`] over a real `AF_UNIX` [`UnixListener`]; edda's
//!   reusable `UnixEguiWire` dials the exact socket [`serve_robot_remote`] binds.
//!
//! ## Member socket name
//! The fleet dials `<dir>/holger.sock` — the socket BASENAME is the member name
//! (`holger`). [`serve_robot_remote`] binds whatever path the `--robot-remote <socket>`
//! flag hands it; the fleet is the side that chooses the `holger.sock` basename.
//!
//! ## 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
//! (and never links the wire/kittest stack).

use std::io::{self, BufReader};
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::sync::Arc;

use nornir_robotui::cmd_json::{CmdJsonServer, IoChannel};
use nornir_robotui::egui::Vec2;
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{Observable, RobotSession};

use server_lib::exposed::fast_routes::FastRoutes;
use server_lib::LocalHolger;
use traits::{
    ArchiveInfo, ArtifactEntry, ArtifactFormat, ArtifactId, HolgerObject, RepositoryBackendTrait,
};

use crate::app::HolgerUiApp;
use crate::data::UiData;

/// The holger operator shell wrapped as an [`Observable`] — the robot reads exactly what
/// the shell renders through the SAME [`HolgerUiApp::state_json`] the co-located robot
/// suite asserts (no twinned oracle). The WHOLE tabbed shell is mounted, so a driver can
/// read/act on every operator tab, not one pane.
struct UiApp(HolgerUiApp);

impl Observable for UiApp {
    fn state_json(&self) -> serde_json::Value {
        self.0.state_json()
    }
}

/// A present-but-populated repository backend so the mounted shell lists real repos
/// (`repo_count > 0`) without depending on any one ecosystem's on-disk format. The
/// surface under test is the egui app + its `state_json` — the SAME stub shape the
/// co-located `tests/robot_ui_app.rs` uses.
struct StubRepo {
    name: &'static str,
    writable: bool,
}

impl RepositoryBackendTrait for StubRepo {
    fn name(&self) -> &str {
        self.name
    }
    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Rust
    }
    fn is_writable(&self) -> bool {
        self.writable
    }
    fn fetch(&self, _id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
        Ok(None)
    }
    fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
        Ok(())
    }
    fn list(&self, _name_filter: Option<&str>, _limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
        Ok(Vec::new())
    }
    fn archive_files(&self, _prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
        Ok(Vec::new())
    }
    fn archive_info(&self) -> anyhow::Result<ArchiveInfo> {
        Ok(ArchiveInfo {
            file_count: 0,
            total_uncompressed_bytes: 0,
            archive_path: self.name.to_string(),
        })
    }
    fn handle_http2_request(
        &self,
        _method: &str,
        _suburl: &str,
        _body: &[u8],
    ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
        Ok((404, Vec::new(), Vec::new()))
    }
}

/// A real holger-server-ui app over a `LocalHolger` seeded with three repositories (one
/// writable → the server-derived profile is DYNAMIC, so every operator tab is reachable),
/// with the splash skipped so the robot starts on the tabs. Offline-safe: `LocalHolger`
/// is in-process, so mounting the server dials no network — the SAME headless build the
/// in-crate robot tests use.
fn seeded_app() -> HolgerUiApp {
    let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
        ("rust-dev".to_string(), Arc::new(StubRepo { name: "rust-dev", writable: true })),
        ("cache".to_string(), Arc::new(StubRepo { name: "cache", writable: false })),
        ("sparring".to_string(), Arc::new(StubRepo { name: "sparring", writable: false })),
    ];
    let holger: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(FastRoutes::new(repos)));
    let data = UiData::new(holger).expect("holger-server-ui view-model runtime");
    let mut app = HolgerUiApp::new(data);
    app.skip_splash();
    app
}

/// Build a [`CmdJsonServer`] over a [`RobotSession`] driving the WHOLE holger operator
/// shell through its real `egui_kittest` harness — the host side a driver dials. This is
/// the SAME `build_state(|ctx, a| a.draw_ui(ui), …)` harness the co-located robot suite
/// builds (REUSE), so mounting the server dials no network.
fn robot_session() -> CmdJsonServer<RobotSession<'static, UiApp>> {
    // A generously sized canvas so the whole tab bar + the active pane lay out and are
    // hit-testable (mirrors the kittest harness in `tests/robot_ui_app.rs`).
    let harness = Harness::builder()
        .with_size(Vec2::new(1100.0, 720.0))
        .build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
    let mut session = RobotSession::new(harness, "holger");
    session.run(2); // settle the first layout 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
/// 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.
///
/// The fleet dials `<dir>/holger.sock`; the caller passes that path here.
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!("holger-server-ui: 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!("holger-server-ui: robot-remote connection ended: {e}");
                }
            }
            Err(e) => eprintln!("holger-server-ui: robot-remote stream clone failed: {e}"),
        }
    }
}

// ── the REAL-SOCKET proof — holger 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 holger operator 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_holger_shell_over_a_real_unix_socket() {
        let dir = tempfile::tempdir().unwrap();
        // The member socket the fleet dials is `<dir>/holger.sock` (basename == member).
        let sock = dir.path().join("holger.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 holger shell opens on the Status tab.
        match call!(CmdRequest::ReadStateAt { pointer: "/tab".into() }) {
            CmdResponse::StateAt { value } => {
                assert_eq!(value, Some(json!("status")), "remote shell opens on Status")
            }
            other => panic!("want StateAt, got {other:?}"),
        }

        // locate-by-testid: the Graph tab resolves to a real node id remotely.
        match call!(CmdRequest::Locate { target: Target::label("Graph") }) {
            CmdResponse::Located { id } => {
                assert!(id.is_some(), "the Graph 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("Graph".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 holger UI actually changed.
        match call!(CmdRequest::ReadStateAt { pointer: "/tab".into() }) {
            CmdResponse::StateAt { value } => assert_eq!(
                value,
                Some(json!("graph")),
                "the click moved the REMOTE holger 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!("status") },
        }) {
            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!("graph") },
        }) {
            CmdResponse::Asserted { pass: true, reason: None } => {}
            other => panic!("the true state must pass over the wire: {other:?}"),
        }

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "holger-server-ui",
            "wire_remote_socket_drivable",
            true,
            "holger-server-ui MOUNTS a cmd_json::CmdJsonServer over its egui operator shell on a \
             real AF_UNIX socket (basename `holger.sock` — the member name the fleet dials); \
             a CmdJsonClient pings, locates + CLICKS the Graph tab, and the wired-back \
             state_json shows the REMOTE active tab changed status→graph — holger flipped \
             from probe-only to wire-driven (red-when-broken)",
        );
    }

    /// The per-tab SCENARIO LIBRARY proof: EVERY recorded
    /// [`Scenario`](nornir_robotui::scenario::Scenario) from
    /// [`crate::scenarios::holger_tab_scenarios`] is driven, step by step, against the
    /// LIVE mounted holger operator 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. The recorded tab set is ALSO
    /// asserted equal to the shell's OWN `/tabs` roster, so a removed/renamed/added tab
    /// (or a stale library) goes red. This proves the scenarios drive REAL holger, not a
    /// mock.
    #[test]
    fn drives_every_recorded_tab_scenario_over_a_real_unix_socket() {
        use crate::scenarios::{holger_tab_scenario_library, holger_tab_scenarios};

        let dir = tempfile::tempdir().unwrap();
        let sock = dir.path().join("holger-tab-scenarios.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,
        ));

        // 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 (the slot ids) MUST
        // equal the shell's OWN `/tabs` roster (a removed/renamed/added tab here goes red).
        let lib = holger_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 holger_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:?}")),
                }
            }
        }

        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,
            holger_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!(
            "holger 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(
            "holger-server-ui",
            "per_tab_scenarios_drive_live_shell",
            failures.is_empty(),
            "the recorded per-tab robot SCENARIO LIBRARY (scenarios::holger_tab_scenarios) \
             drives EVERY holger operator 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 → /tab moved), asserted to have DREW, and \
             asserted to fold its OWN state_json identity — Lineage + Tools also drive a REAL \
             within-pane state change; the recorded set equals the shell's /tabs roster — the \
             fleet's RobotDriveMember now has a specific-tab script, not a bare liveness probe \
             (red-when-broken)",
        );
    }
}