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/saga.rs

//! **The Saga tape-recorder tab** (TASK #42) — record → warehouse → REPLAY, for the
//! **SELECTED workspace**, where a human scrubs a recorded user journey.
//!
//! Thin host wrapper over the reuse pane [`dwarves_embed::SagaView`]: it holds that
//! pane (built from the selected workspace's saga tape) and, when the warehouse
//! selector broadcasts `{"set_workspace":…}`, RELOADS the tape for the newly-selected
//! workspace — the SAME binding gesture the Release + Benches tabs use. The pane
//! itself owns the REUSE: the `nornir_saga::ui::Player` transport, the `saga_to_ui`
//! event→movement bridge, `summarize_sessions` (painful-first rail), `ReplayPlan`,
//! and the `SessionFrustration` RED stuck-step marker. Nothing here re-implements a
//! transport; this wrapper only re-binds + forwards.

use facett_core::{Facet, Panel};

/// The Saga tape-recorder tab: the reused pane, re-bound to the selected workspace.
pub struct SagaTab {
    /// The reused `dwarves_embed::SagaView` pane for the current workspace.
    inner: Box<dyn Panel>,
    /// The active workspace the pane is bound to (`""` = none → sample).
    workspace: String,
}

impl SagaTab {
    /// Bind to `workspace` (may be `""`) and load its saga tape now.
    pub fn new(workspace: impl Into<String>) -> Self {
        let workspace = workspace.into();
        Self {
            inner: dwarves_embed::saga_panel(&workspace),
            workspace,
        }
    }

    /// The tab with no workspace selected — the honest EMPTY tab (tests /
    /// pre-selection). It was called `sample()` and it drew a sample; it draws the
    /// named empty state over the tape path now.
    #[allow(dead_code)]
    pub fn unbound() -> Self {
        Self::new("")
    }

    /// **Re-bind the pane to `workspace`** — rebuild the reused pane from that
    /// workspace's saga tape (the named empty state on absence). Never panics.
    pub fn bind(&mut self, workspace: &str) {
        self.workspace = workspace.to_string();
        self.inner = dwarves_embed::saga_panel(workspace);
    }

    /// The workspace the pane is bound to (`""` = none → sample).
    #[allow(dead_code)]
    pub fn workspace(&self) -> &str {
        &self.workspace
    }
}

impl Facet for SagaTab {
    fn title(&self) -> &str {
        "Saga"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        self.inner.ui(ui);
    }

    fn state_json(&self) -> serde_json::Value {
        // The reused pane's state is already self-complete (component, sessions,
        // active_session, steps, stage_state, source) — pass it through.
        self.inner.state_json()
    }

    fn update_json(&mut self, msg_json: &str) {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
            // The selector's broadcast (the SAME `{"set_workspace":…}` every tab gets):
            // re-bind to the newly-selected workspace.
            if let Some(ws) = v.get("set_workspace").and_then(|t| t.as_str()) {
                self.bind(ws);
                return;
            }
        }
        // Anything else forwards to the reused pane (select/seek/step/speed/play).
        self.inner.update_json(msg_json);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tabbed::TabbedView;
    use nornir_saga::{SagaEvent, SessionCtx};

    /// The route [`recorded_tape`] gets stuck on — two errors land here, so this is the
    /// frame the scrub assertion expects to find on the stage.
    ///
    /// It is a named constant precisely because that assertion used to name a route from
    /// a DIFFERENT tape: `dwarves_embed::sample_tape()`'s `/offert/7`, left behind when
    /// that shipped sample was deleted. The test then recorded one journey and asserted
    /// against another — a test that can only ever fail, and, had the string happened to
    /// match, one that proved nothing.
    const STUCK_ROUTE: &str = "/two";

    /// A two-session tape THIS TEST records, then imports — a stuck session (repeated
    /// errors on [`STUCK_ROUTE`]) plus a calm one, so the rail has a painful-first
    /// ordering and a RED step to scrub to.
    ///
    /// It replaced `dwarves_embed::sample_tape()`, which was the same shape but was
    /// SHIPPED: the Saga pane replayed it whenever nothing had been imported, so an
    /// empty recorder drew an invented user journey through a real product. Recording
    /// a tape inside the test that then reads it back is the honest version of the
    /// same coverage.
    fn recorded_tape() -> Vec<SagaEvent> {
        let ctx = SessionCtx::new("gui-tape-1", "tester", "demo-app", "0.0.1", "0000000");
        let mut evs: Vec<SagaEvent> = Vec::new();
        let mut push = |mut e: SagaEvent, seq: u64| {
            e = e.in_session(&ctx);
            e.seq = seq;
            e.ts_micros = 1_700_000_000_000_000 + seq * 300_000;
            evs.push(e);
        };
        push(
            SagaEvent::nav("/one", "First", "open", r#"{"route":"/one"}"#),
            0,
        );
        push(
            SagaEvent::click("/one", "Form", "next", r#"{"opened":true}"#),
            1,
        );
        push(
            SagaEvent::nav(STUCK_ROUTE, "Form", "open", r#"{"route":"/two"}"#),
            2,
        );
        // Two errors on the SAME route ⇒ a stuck route ⇒ a RED step.
        let mut e1 = SagaEvent::error("Form", "field required");
        e1.route = STUCK_ROUTE.into();
        push(e1, 3);
        push(
            SagaEvent::click(STUCK_ROUTE, "Save", "submit", r#"{"ok":false}"#),
            4,
        );
        let mut e2 = SagaEvent::error("Form", "field required again");
        e2.route = STUCK_ROUTE.into();
        push(e2, 5);

        let ctx2 = SessionCtx::new("gui-tape-2", "tester", "demo-app", "0.0.1", "0000000");
        let mut push2 = |mut e: SagaEvent, seq: u64| {
            e = e.in_session(&ctx2);
            e.seq = seq;
            e.ts_micros = 1_700_000_100_000_000 + seq * 200_000;
            evs.push(e);
        };
        push2(
            SagaEvent::nav("/calm", "Calm", "open", r#"{"route":"/calm"}"#),
            10,
        );
        push2(
            SagaEvent::click("/calm", "Report", "open", r#"{"report":"weekly"}"#),
            11,
        );
        evs
    }

    /// ROBOT SCENARIO (TASK #42) — the **Saga** tab mounted in the shell and driven
    /// over the remote seam (`state_json`/`update_json`), the SAME channel a
    /// `nornir-robotui` scenario / thin client use. Import a fixture tape into a
    /// workspace, re-bind to it, and assert the tab reflects THAT tape (source →
    /// `tape`, sessions rail populated painful-first, a RED stuck step); then scrub
    /// (seek) and assert the stage shows the stuck frame's state_json. Emits a
    /// `nornir-testmatrix` cell (reuse edda).
    #[test]
    fn robot_saga_tab_over_the_seam() {
        // Hold ENV_LOCK: this test SEEDS a `.jsonl` tape under `std::env::temp_dir()`
        // (`workspace_saga_tape_path`) then reads it BACK asserting `source == "tape"`.
        // Any concurrent test that repoints TMPDIR (the Release `scratch_tmp` tests, the
        // hermetic robot_remote shell) would make the read-back miss the tape — so
        // serialize on the same lock they use.
        let _env = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let view: Box<dyn Panel> = Box::new(SagaTab::unbound());
        let mut app = TabbedView::new("dwarves — Saga", vec![("Saga".into(), view)]);

        // NO workspace: the tab is honestly EMPTY and NAMES the tape feed. It used to
        // replay the deleted njord `sample_tape()` here (2026-09-10 audit).
        Facet::update_json(&mut app, r#"{"set_tab":"Saga"}"#);
        let st = Facet::state_json(&app);
        let pane = &st["saga"];
        let sample_ok = pane["component"] == "SagaView"
            && pane["source"] == "empty"
            && pane["session_count"] == 0
            && pane["tape_empty"]["empty"] == true
            && pane["tape_empty"]["feed"]
                .as_str()
                .unwrap_or("")
                .contains("saga tape");

        // Seed a real tape for a workspace and re-bind to it → the tab reflects THAT.
        let ws = "dwarves-gui-saga-ws";
        let tape = dwarves_embed::workspace_saga_tape_path(ws);
        let _ = std::fs::remove_file(&tape);
        {
            use nornir_saga::SagaSink;
            nornir_saga::JsonFileSink::new(tape.clone())
                .write(&recorded_tape())
                .unwrap();
        }
        Facet::update_json(
            &mut app,
            &serde_json::json!({ "set_workspace": ws }).to_string(),
        );
        let st = Facet::state_json(&app);
        let pane = &st["saga"];
        let bound_ok = pane["selected_workspace"] == ws
            && pane["source"] == "tape"
            && pane["session_count"].as_u64().unwrap_or(0) >= 2
            && pane["sessions"][0]["stuck"] == true
            && pane["diverged_count"].as_u64().unwrap_or(0) >= 1;

        // Scrub to the stuck step and assert the stage shows that frame's state_json.
        let steps = pane["steps"].as_array().unwrap();
        let red = steps.iter().find(|s| s["diverged"] == true).unwrap();
        let red_index = red["index"].as_u64().unwrap();
        Facet::update_json(
            &mut app,
            &serde_json::json!({ "seek": red_index + 1 }).to_string(),
        );
        let st = Facet::state_json(&app);
        let pane = &st["saga"];
        // The stage shows the stuck frame's own `state_json` — so it must name the route
        // THIS TEST recorded the errors on. It used to assert `/offert/7`, a route from
        // the deleted shipped `sample_tape()`; nothing in this test has ever produced it.
        let scrub_ok = pane["current_diverged"] == true
            && pane["stage_state"].as_str().unwrap().contains(STUCK_ROUTE);

        let _ = std::fs::remove_file(&tape);

        let drivable = sample_ok && bound_ok && scrub_ok;
        assert!(
            drivable,
            "the Saga tab drives + re-binds + scrubs over the remote seam: {st}"
        );

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "saga_tab_drivable",
            drivable,
            "Saga tape-recorder tab (TASK #42): reused dwarves_embed::SagaView (nornir_saga Player transport + saga_to_ui bridge + summarize_sessions rail + RED stuck marker), re-binding to the selected workspace's imported `.jsonl` tape (sample vs tape) and scrubbing to the stuck frame's state_json — driven headless over state_json/update_json",
        );
        let _ = drivable;
    }
}