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;
}
}