crates/dwarves-embed/src/sagaview.rs
//! **The dwarves SAGA tape-recorder pane** (TASK #42) — record → warehouse → REPLAY.
//!
//! "dwarves is a tape recorder UI for saga." A leaf app records a user's session as
//! a `SagaEvent` stream; `dwarves saga import` lands it into the per-workspace
//! warehouse **tape** (`.jsonl`); this pane lists the recorded sessions (painful
//! first), and **scrubs / plays / steps** the selected one, re-rendering the app's
//! state over time — a VCR for real journeys.
//!
//! ## What it REUSES (no twins — the engine already exists)
//! * **transport** — `nornir_saga::ui::Player`: `step`/`seek`/`seek_ms`/`set_speed`/
//! `next_delay_ms`. The pane does NOT invent play/seek/speed math; it drives Player.
//! * **event→movement bridge** — `nornir_saga::ui::saga_to_ui` folds the stored
//! `SagaEvent` stream into the `Saga` Player replays (the one load-bearing new
//! primitive, in the lean crate).
//! * **sessions list** — `nornir_saga::summarize_sessions` → `SagaSessionSummary`
//! (painful-first), and `SessionFrustration::stuck_routes` for the RED marker.
//! * **replay plan** — `nornir_saga::ReplayPlan::for_session` → the ordered
//! `ReplayStep`s (the step list + each step's `source_seq`).
//!
//! ## The store seam (`SagaTapeStore` — file OR warehouse, one pane) — TASK #42 host
//! The pane holds a `Box<dyn nornir_saga::SagaTapeStore>` and NEVER reads a `.jsonl`
//! itself: the sessions rail comes from `store.list_sessions()` (painful-first) and a
//! selected session's events from `store.read_session(id)` — the SAME trait a
//! file-backed, warehouse-backed, or gRPC-fronted store exposes. A host injects the
//! store via [`SagaView::from_store`]; whatever it hands in, the pane drives the
//! reused `nornir_saga::ui::Player` identically.
//!
//! * **Default (this crate, thin/offline):** a [`nornir_saga::FileTapeStore`] over
//! the workspace's saga tape (`dwarves::saga::workspace_saga_tape_path`) — the
//! artifact `dwarves saga import` lands, the same file seam the Release/Benches
//! panes use. Missing/empty ⇒ **no sessions at all**, and the pane draws the shared
//! [`facett_core::empty::EmptyState`] naming the tape file it wanted.
//!
//! **The sample tape is gone (audit 2026-09-10).** This used to fall back to an
//! in-memory [`nornir_saga::MemTapeStore`] of `sample_tape()` — two invented njord
//! sessions, one of them walking `/kund` → `OffertForm` → `/offert/7` and getting
//! STUCK there with two `validation failed: missing org_nr` errors, painted RED in
//! the rail and on the transport. That is a bug report about a real product, drawn
//! by a pane that had recorded nothing. `state_json`'s `source` is now `tape` or
//! `empty`, and `empty` carries the named block.
//! * **Fat (a host that LINKS nornir):** hand `from_store` a
//! `nornir::warehouse::WarehouseTapeStore::new(embedded IcebergWarehouse)` — it
//! implements the SAME `SagaTapeStore` over the live Mimir `saga_events` table, so
//! the pane replays straight from the warehouse with ZERO pane changes. dwarves is
//! a LEAF of nornir (a `dwarves → nornir` edge would cycle), so THIS crate cannot
//! construct that store; the injection seam is exactly what lets nornir's own host
//! drop it in. See the `TODO(#42 fat/thin)` on [`saga_panel`].
//! * **Thin (gRPC):** once a gRPC warehouse client implements
//! `nornir_warehouse_trait::Warehouse` (append_arrow/scan_arrow), wrap it in the
//! SAME `WarehouseTapeStore` and inject — again no pane changes. Not wired yet
//! (the current `nornir-warehouse-embed` client is a `BuildInfoSink` write RPC,
//! not a `dyn Warehouse`).
use std::path::{Path, PathBuf};
use facett_core::empty::EmptyState;
use facett_core::{Facet, Panel};
use nornir_saga::ui::{saga_to_ui, Player};
use nornir_saga::{
FileTapeStore, ReplayPlan, SagaEvent, SagaSessionSummary, SagaTapeStore, SessionFrustration,
};
/// The tape feed, named the way an operator would go look it up.
const TAPE_FEED: &str = "dwarves saga tape (.jsonl)";
/// The saga tape a `dwarves saga import` run appends to for a named workspace — the
/// per-workspace analogue of the Release pane's convergence path. Re-exported from
/// the pure `dwarves::saga` module so the CLI verb + this pane share ONE path.
pub use dwarves::saga::workspace_saga_tape_path;
/// One recorded session, distilled for the pane: its roll-up summary + the events
/// (kept so selecting it can build a `Player` + `ReplayPlan` with no re-read).
struct SessionTape {
summary: SagaSessionSummary,
}
/// The **Saga tape-recorder** pane: a sessions rail (painful-first) + a transport
/// bar bound to a `Player` + a stage rendering the current frame's state. A
/// [`Facet`] any egui host drops into a tab (the `Facet: Panel` blanket makes it a
/// `Box<dyn Panel>`).
pub struct SagaView {
/// The workspace this pane is bound to (`""` = none → the shared default tape).
workspace: String,
/// Whether the sessions came from a real tape/warehouse.
from_tape: bool,
/// Set when the store listed no sessions — the NAMED empty state (which tape, why),
/// drawn in place of the rail. This is what replaced the invented njord sample.
empty: Option<EmptyState>,
/// The tape store the pane replays from — file-backed, warehouse-backed, or
/// gRPC-fronted, all behind the ONE `SagaTapeStore` trait (never a raw `.jsonl`
/// read). `list_sessions` fills the rail; `read_session` feeds the Player.
store: Box<dyn SagaTapeStore>,
/// The rolled-up sessions (painful-first).
sessions: Vec<SessionTape>,
/// The selected session id (`None` ⇒ nothing selected yet).
active: Option<String>,
/// The transport for the active session (reused `nornir_saga::ui::Player`).
player: Option<Player>,
/// The active session's replay plan (the step list + `source_seq`s).
plan: Option<ReplayPlan>,
/// Per-step RED "diverged / stuck" flags, aligned 1:1 with `plan.steps` (and the
/// Player's frames): a step whose route the user got stuck on (repeated errors)
/// is a RED dot naming its `source_seq` — the Playwright-trace red marker.
diverged: Vec<bool>,
/// Playing (auto-advancing) vs paused.
playing: bool,
/// egui input time of the last auto-advance (for `next_delay_ms` pacing).
last_advance_s: f64,
}
impl SagaView {
/// Build the pane for `workspace` by reading its saga tape. No tape ⇒ no sessions
/// and the named empty state (never the deleted njord sample).
pub fn for_workspace(workspace: &str) -> Self {
let tape = workspace_saga_tape_path(workspace);
Self::from_tape_path(&tape, workspace)
}
/// Build from an explicit tape path (the helper the tab + tests drive) — the
/// "load a `.jsonl` tape from disk" seam. Wraps the file in a [`FileTapeStore`]
/// (REUSE — no raw `JsonFileSink` read here). An absent/empty tape gives an EMPTY
/// pane carrying the named tape path; never panics, never a sample.
pub fn from_tape_path(tape: &Path, workspace: &str) -> Self {
let file = FileTapeStore::new(tape.to_path_buf());
let has_tape = !file.list_sessions().unwrap_or_default().is_empty();
let mut me = Self::from_store(Box::new(file), workspace, has_tape);
if !has_tape {
let ws = if workspace.trim().is_empty() {
"no workspace selected".to_string()
} else {
format!("workspace `{workspace}`")
};
me.empty = Some(
EmptyState::no_rows(
TAPE_FEED,
format!("{} holds no recorded sessions for {ws}", tape.display()),
)
.with_hint("`dwarves saga import <file.jsonl>`"),
);
}
me
}
/// **Build the pane over any injected [`SagaTapeStore`]** — the fat/thin seam.
/// A host that links nornir hands in a `WarehouseTapeStore` (live Mimir); this
/// crate hands in a `FileTapeStore`/`MemTapeStore`. `from_tape` sets the
/// `source` label (`tape` vs `sample`). The rail comes from `list_sessions`
/// (painful-first); the pane auto-selects the most painful so it is never blank.
pub fn from_store(store: Box<dyn SagaTapeStore>, workspace: &str, from_tape: bool) -> Self {
let summaries = store.list_sessions().unwrap_or_default();
let sessions: Vec<SessionTape> = summaries
.into_iter()
.map(|summary| SessionTape { summary })
.collect();
let empty = sessions.is_empty().then(|| {
EmptyState::no_rows(TAPE_FEED, "the injected tape store listed no sessions")
.with_hint("`dwarves saga import <file.jsonl>`")
});
let mut me = Self {
workspace: workspace.to_string(),
from_tape,
empty,
store,
sessions,
active: None,
player: None,
plan: None,
diverged: Vec::new(),
playing: false,
last_advance_s: 0.0,
};
// Auto-select the first (most painful) session so the tab is never blank.
if let Some(first) = me.sessions.first().map(|s| s.summary.session_id.clone()) {
me.select(&first);
}
me
}
/// Re-bind to a new workspace (the selector's `{"set_workspace":…}` broadcast) —
/// reload the tape + sessions for that workspace. Never panics.
pub fn bind(&mut self, workspace: &str) {
*self = Self::for_workspace(workspace);
}
/// The workspace the pane is bound to (`""` = none → sample).
pub fn workspace(&self) -> &str {
&self.workspace
}
/// **Select a session**: build its `ReplayPlan` + a `Player` (via `saga_to_ui`)
/// and compute the per-step RED "stuck" markers. Unknown id ⇒ cleared selection.
pub fn select(&mut self, session_id: &str) {
// Replay via the store's `read_session` (already `seq`-ordered + scoped) —
// the file/warehouse trait, never a raw file read.
let mine: Vec<SagaEvent> = self.store.read_session(session_id).unwrap_or_default();
if mine.is_empty() {
self.active = None;
self.player = None;
self.plan = None;
self.diverged.clear();
return;
}
// `for_session`/`SessionFrustration::for_session` filter by `session_id`, so
// the already-scoped `mine` slice feeds them identically to the full tape.
let plan = ReplayPlan::for_session(session_id, &mine);
// The one bridge: stored events → the `Saga` the Player replays. Actionable
// (nav/click) only, so its movements line up 1:1 with the plan's steps.
let player = Player::new(saga_to_ui(&mine));
// RED marker: a step whose route the user got STUCK on (repeated errors —
// `SessionFrustration.stuck_routes`) is diverged; it names its `source_seq`.
let fr = SessionFrustration::for_session(session_id, &mine);
let diverged: Vec<bool> = plan
.steps
.iter()
.map(|s| {
let key = if s.route.is_empty() {
s.component.as_str()
} else {
s.route.as_str()
};
fr.stuck_routes.iter().any(|r| r == key)
})
.collect();
self.active = Some(session_id.to_string());
self.player = Some(player);
self.plan = Some(plan);
self.diverged = diverged;
self.playing = false;
}
/// The index of the current step (the last one applied): `pos-1`, or `None` at
/// the very start (nothing applied yet).
fn current_index(&self) -> Option<usize> {
let pos = self.player.as_ref()?.pos();
(pos >= 1).then(|| pos - 1)
}
/// The `state_json` the ORIGINAL session observed at the current step (the app
/// snapshot the user saw) — the stage content. Empty when nothing is applied.
fn stage_state(&self) -> String {
let (Some(plan), Some(i)) = (self.plan.as_ref(), self.current_index()) else {
return String::new();
};
plan.steps
.get(i)
.map(|s| s.expect_state.clone())
.unwrap_or_default()
}
/// Whether the current step is RED (a stuck / diverged step).
fn current_diverged(&self) -> bool {
self.current_index()
.and_then(|i| self.diverged.get(i))
.copied()
.unwrap_or(false)
}
/// The number of RED (stuck/diverged) steps in the active session.
fn diverged_count(&self) -> usize {
self.diverged.iter().filter(|d| **d).count()
}
}
impl Facet for SagaView {
fn title(&self) -> &str {
"Saga"
}
fn ui(&mut self, ui: &mut egui::Ui) {
// ── auto-advance while playing (paced by the reused Player::next_delay_ms) ──
let now_s = ui.input(|i| i.time);
if self.playing {
if let Some(p) = self.player.as_mut() {
if p.finished() {
self.playing = false;
} else {
let delay = (p.next_delay_ms().max(1)) as f64 / 1000.0;
if now_s - self.last_advance_s >= delay {
p.step();
self.last_advance_s = now_s;
}
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(33));
}
}
}
// ── header: workspace + real-tape vs sample (plain words) ──
ui.horizontal(|ui| {
if self.workspace.is_empty() {
ui.label("Saga: no workspace selected");
} else {
ui.label(format!("Saga: {}", self.workspace));
}
ui.separator();
ui.label(if self.from_tape {
"● tape"
} else {
"○ no tape"
});
});
if let Some(e) = &self.empty {
// No recorded sessions ⇒ no rail, no transport, no red "stuck" marker. The
// njord sample that used to fill this space was a bug report about a real
// product, invented by a pane that had recorded nothing.
e.ui(ui);
return;
}
ui.separator();
// Snapshot the intent from the widgets, then apply to the Player after the
// closures (so the borrow checker is happy with `&mut self` inside).
let mut select_session: Option<String> = None;
let mut do_seek: Option<usize> = None;
let mut do_step: i64 = 0;
let mut toggle_play = false;
let mut set_speed: Option<f64> = None;
ui.columns(2, |cols| {
// ── left rail: the sessions (painful-first) ──
cols[0].heading("Sessions");
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.id_salt("saga-sessions")
.show(&mut cols[0], |ui| {
for s in &self.sessions {
let sum = &s.summary;
let is_sel = self.active.as_deref() == Some(sum.session_id.as_str());
let mark = if sum.stuck { "⚠ " } else { "" };
let label = format!(
"{mark}{} · {} · {}▶{}",
short_id(&sum.session_id),
if sum.app.is_empty() {
"?"
} else {
sum.app.as_str()
},
sum.step_count,
if sum.error_count > 0 {
format!(" {}✗", sum.error_count)
} else {
String::new()
}
);
let mut rt = egui::RichText::new(label);
if sum.stuck {
rt = rt.color(egui::Color32::from_rgb(224, 90, 90));
}
if ui.selectable_label(is_sel, rt).clicked() {
select_session = Some(sum.session_id.clone());
}
}
if self.sessions.is_empty() {
ui.weak("no sessions");
}
});
// ── right: the stage (current frame's app state) + step list ──
let stage = &mut cols[1];
stage.heading("Stage");
match (&self.active, self.current_index()) {
(Some(id), Some(i)) => {
let step = self.plan.as_ref().and_then(|p| p.steps.get(i));
if let Some(st) = step {
let red = self.current_diverged();
let head = format!(
"{} · {} {} (seq {})",
short_id(id),
st.kind.as_str(),
if st.route.is_empty() {
st.component.as_str()
} else {
st.route.as_str()
},
st.source_seq
);
if red {
stage.colored_label(
egui::Color32::from_rgb(224, 90, 90),
format!("◀ {head} — STUCK"),
);
} else {
stage.label(head);
}
}
let state = self.stage_state();
let pretty = serde_json::from_str::<serde_json::Value>(&state)
.ok()
.map(|v| serde_json::to_string_pretty(&v).unwrap_or(state.clone()))
.unwrap_or(state);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.id_salt("saga-stage")
.show(stage, |ui| {
ui.monospace(if pretty.is_empty() {
"(no state_json at this step)".to_string()
} else {
pretty
});
});
}
(Some(_), None) => {
stage.weak("at the start — press ▶ or step ⏭ to replay");
}
(None, _) => {
stage.weak("select a session to scrub its tape");
}
}
// ── step list with the RED diverged markers ──
stage.separator();
stage.label("Steps");
let cur = self.current_index();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.id_salt("saga-steps")
.max_height(140.0)
.show(stage, |ui| {
if let Some(plan) = &self.plan {
for (i, st) in plan.steps.iter().enumerate() {
let red = self.diverged.get(i).copied().unwrap_or(false);
let here = cur == Some(i);
let bullet = if here { "▶" } else { " " };
let target = if st.route.is_empty() {
st.component.as_str()
} else {
st.route.as_str()
};
let line =
format!("{bullet} {:>2} {} {}", i + 1, st.kind.as_str(), target);
let mut rt = egui::RichText::new(line).monospace();
if red {
rt = rt.color(egui::Color32::from_rgb(224, 90, 90));
} else if here {
rt = rt.strong();
}
if ui.selectable_label(here, rt).clicked() {
do_seek = Some(i + 1); // seek to just AFTER step i
}
}
}
});
});
// ── transport bar (bound entirely to the reused Player) ──
ui.separator();
ui.horizontal(|ui| {
let (pos, len, dur, t_ms, speed, finished) = match &self.player {
Some(p) => {
let t = p
.pos()
.checked_sub(1)
.and_then(|i| p.event(i))
.map(|e| e.t_ms)
.unwrap_or(0);
(
p.pos(),
p.len(),
p.duration_ms(),
t,
p.speed(),
p.finished(),
)
}
None => (0, 0, 0, 0, 1.0, true),
};
if ui.button("⏮").on_hover_text("rewind").clicked() {
do_seek = Some(0);
}
if ui.button("◀").on_hover_text("step back").clicked() {
do_step = -1;
}
let play_glyph = if self.playing { "⏸" } else { "▶" };
if ui
.button(play_glyph)
.on_hover_text("play / pause")
.clicked()
{
toggle_play = true;
}
if ui.button("▶|").on_hover_text("step forward").clicked() {
do_step = 1;
}
if ui.button("⏭").on_hover_text("to end").clicked() {
do_seek = Some(len);
}
ui.separator();
// The scrubber: a position slider over the step index (seek).
if len > 0 {
let mut p = pos;
let resp = ui.add(egui::Slider::new(&mut p, 0..=len).text("step"));
if resp.changed() {
do_seek = Some(p);
}
}
ui.label(format!("t={t_ms}ms / {dur}ms"));
ui.separator();
// Speed dropdown → Player::set_speed.
egui::ComboBox::from_id_salt("saga-speed")
.selected_text(fmt_speed(speed))
.show_ui(ui, |ui| {
for opt in [0.25f64, 0.5, 1.0, 2.0, 4.0] {
if ui
.selectable_label((speed - opt).abs() < 1e-6, fmt_speed(opt))
.clicked()
{
set_speed = Some(opt);
}
}
});
let _ = finished;
});
// ── apply the collected intents to the Player / selection ──
if let Some(id) = select_session {
self.select(&id);
}
if let Some(sp) = set_speed {
if let Some(p) = self.player.as_mut() {
p.set_speed(sp);
}
}
if toggle_play {
self.playing = !self.playing;
self.last_advance_s = now_s;
}
if do_step != 0 {
if let Some(p) = self.player.as_mut() {
let target = (p.pos() as i64 + do_step).clamp(0, p.len() as i64) as usize;
p.seek(target);
}
}
if let Some(k) = do_seek {
if let Some(p) = self.player.as_mut() {
p.seek(k);
}
}
}
fn state_json(&self) -> serde_json::Value {
let sessions: Vec<serde_json::Value> = self
.sessions
.iter()
.map(|s| {
let x = &s.summary;
serde_json::json!({
"session_id": x.session_id,
"app": x.app,
"user": x.user,
"step_count": x.step_count,
"event_count": x.event_count,
"error_count": x.error_count,
"replayable": x.replayable,
"stuck": x.stuck,
"frustration_score": x.frustration_score,
})
})
.collect();
let steps: Vec<serde_json::Value> = match &self.plan {
Some(plan) => plan
.steps
.iter()
.enumerate()
.map(|(i, st)| {
serde_json::json!({
"index": i,
"kind": st.kind.as_str(),
"route": st.route,
"component": st.component,
"source_seq": st.source_seq,
"diverged": self.diverged.get(i).copied().unwrap_or(false),
})
})
.collect(),
None => Vec::new(),
};
let (pos, len, dur, speed) = match &self.player {
Some(p) => (p.pos(), p.len(), p.duration_ms(), p.speed()),
None => (0, 0, 0, 1.0),
};
let t_ms = self
.player
.as_ref()
.and_then(|p| p.pos().checked_sub(1).and_then(|i| p.event(i)))
.map(|e| e.t_ms)
.unwrap_or(0);
serde_json::json!({
"component": "SagaView",
"selected_workspace": self.workspace,
"release_target": self.workspace,
"source": if self.from_tape { "tape" } else { "empty" },
"tape_feed": TAPE_FEED,
"tape_empty": self.empty.as_ref().map(|e| e.state_json()),
"session_count": self.sessions.len(),
"sessions": sessions,
"active_session": self.active,
"pos": pos,
"len": len,
"t_ms": t_ms,
"duration_ms": dur,
"speed": speed,
"playing": self.playing,
"steps": steps,
"diverged_count": self.diverged_count(),
// The stage: the app state_json the user saw at the current step + whether
// that step is RED (stuck/diverged).
"current_index": self.current_index(),
"current_diverged": self.current_diverged(),
"stage_state": self.stage_state(),
})
}
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
return;
};
// The selector's broadcast: 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;
}
// The pane's control surface (drives it headless from a robot test):
if let Some(id) = v.get("select_session").and_then(|t| t.as_str()) {
self.select(id);
return;
}
if let Some(p) = self.player.as_mut() {
if let Some(k) = v.get("seek").and_then(|t| t.as_u64()) {
p.seek(k as usize);
return;
}
if let Some(ms) = v.get("seek_ms").and_then(|t| t.as_u64()) {
p.seek_ms(ms);
return;
}
if let Some(d) = v.get("step").and_then(|t| t.as_i64()) {
let target = (p.pos() as i64 + d).clamp(0, p.len() as i64) as usize;
p.seek(target);
return;
}
if let Some(sp) = v.get("speed").and_then(|t| t.as_f64()) {
p.set_speed(sp);
return;
}
}
if let Some(play) = v.get("play").and_then(|t| t.as_bool()) {
self.playing = play;
}
}
}
/// The dwarves UI **Saga** tab for a workspace: replays that workspace's saga tape
/// through a [`FileTapeStore`]. No tape ⇒ no sessions and the NAMED empty state over
/// that path. Host-agnostic + offline (no server / no gRPC).
///
/// TODO(#42 fat): a host that LINKS nornir should call [`saga_panel_from_store`]
/// with `nornir::warehouse::WarehouseTapeStore::new(embedded IcebergWarehouse)` to
/// replay straight from the live Mimir `saga_events` table. This crate cannot
/// construct it: dwarves is a LEAF of nornir (a `dwarves → nornir` edge cycles), and
/// the concrete `IcebergWarehouse` + `WarehouseTapeStore` live in nornir. The
/// injection seam ([`saga_panel_from_store`]) is what lets nornir's own host drop it
/// in with zero pane changes.
///
/// TODO(#42 thin): wrap the gRPC warehouse in `WarehouseTapeStore` once the client
/// implements `nornir_warehouse_trait::Warehouse` (append_arrow/scan_arrow). Today's
/// `nornir-warehouse-embed` client is a `BuildInfoSink` write RPC, not a `dyn
/// Warehouse`, so there is nothing to wrap yet — the pane stays on the file seam.
pub fn saga_panel(workspace: &str) -> Box<dyn Panel> {
Box::new(SagaView::for_workspace(workspace))
}
/// Build the pane from an explicit tape path (a host/CLI that resolved it, or a
/// test) — the "load a `.jsonl` tape from disk" seam. Missing/empty ⇒ the sample.
pub fn saga_panel_at(tape: &Path, workspace: &str) -> Box<dyn Panel> {
Box::new(SagaView::from_tape_path(tape, workspace))
}
/// **Build the pane over any injected [`SagaTapeStore`]** — the fat/thin seam a host
/// that has a warehouse-backed store (nornir's `WarehouseTapeStore` over an embedded
/// `IcebergWarehouse`, or a future gRPC `dyn Warehouse`) drops in so the pane
/// replays from the live Mimir `saga_events` table with no pane changes.
pub fn saga_panel_from_store(
store: Box<dyn SagaTapeStore>,
workspace: &str,
from_tape: bool,
) -> Box<dyn Panel> {
Box::new(SagaView::from_store(store, workspace, from_tape))
}
/// Convenience for a test/host that wants the resolved tape path for a workspace.
pub fn saga_tape_path(workspace: &str) -> PathBuf {
workspace_saga_tape_path(workspace)
}
/// Format a speed multiplier without a trailing `.0` (`1×`, `0.25×`, `2×`).
fn fmt_speed(speed: f64) -> String {
if (speed.fract()).abs() < 1e-9 {
format!("{}×", speed as i64)
} else {
format!("{speed}×")
}
}
/// Short session-id for the rail (keeps long uuids readable).
fn short_id(id: &str) -> String {
if id.len() > 14 {
format!("{}…", &id[..13])
} else {
id.to_string()
}
}
// ── the sample tape (the never-blank fallback + the robot oracle) ─────────────
/// A njord-shaped tape for a TEST — a session that walks /kund → OffertForm →
/// /offert/7 and gets STUCK there (two errors), plus a calmer second session, so the
/// rail has a painful-first ordering and one RED step to find.
///
/// **`#[cfg(test)]` since 2026-09-10.** As `pub fn sample_tape()` this was what the
/// SHIPPED pane replayed whenever no tape had been imported: an invented user journey
/// through a real product, with invented validation errors, painted red.
#[cfg(test)]
pub(crate) fn sample_tape() -> Vec<SagaEvent> {
// Test-only since the sample stopped shipping — importing it at module scope would
// now be an unused import in every real build.
use nornir_saga::SessionCtx;
let ctx = SessionCtx::new("njord-sample-1", "alice", "njord", "0.9.0", "abc123f");
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; // +300ms apart
evs.push(e);
};
push(
SagaEvent::nav(
"/kund",
"KundList",
"open",
r#"{"route":"/kund","kunder":3}"#,
),
0,
);
push(
SagaEvent::click("/kund", "OffertForm", "new_offert", r#"{"opened":true}"#),
1,
);
push(
SagaEvent::nav(
"/offert/7",
"OffertForm",
"open",
r#"{"route":"/offert/7","org_nr":""}"#,
),
2,
);
// The user gets stuck: two errors on /offert/7 (a stuck route → RED step).
let mut err1 = SagaEvent::error("OffertForm", "validation failed: missing org_nr");
err1.route = "/offert/7".into();
push(err1, 3);
push(
SagaEvent::click("/offert/7", "Save", "submit", r#"{"ok":false}"#),
4,
);
let mut err2 = SagaEvent::error("OffertForm", "validation failed again: missing org_nr");
err2.route = "/offert/7".into();
push(err2, 5);
// A calmer second session so the rail has more than one row (float painful first).
let ctx2 = SessionCtx::new("njord-sample-2", "carol", "njord", "0.9.0", "abc123f");
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(
"/dashboard",
"Dashboard",
"open",
r#"{"route":"/dashboard"}"#,
),
10,
);
push2(
SagaEvent::click("/dashboard", "Report", "open", r#"{"report":"weekly"}"#),
11,
);
evs
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_saga::MemTapeStore;
/// **THE NJORD SAMPLE TAPE IS GONE** (audit 2026-09-10). With no imported tape the
/// pane used to replay `sample_tape()`: two invented sessions through a real
/// product, one of them STUCK on `/offert/7` with two
/// `validation failed: missing org_nr` errors — a bug report, drawn in red, by a
/// recorder that had recorded nothing. Pins:
///
/// (a) an absent tape gives ZERO sessions and no active session;
/// (b) the pane carries the NAMED empty state over the tape path;
/// (c) `source` is `empty`, never `sample`;
/// (d) none of the sample's invented routes/session ids/errors survives.
///
/// RED-when-broken: restore any fallback store and (a)/(d) fail.
#[test]
fn an_unrecorded_workspace_names_its_tape_never_a_sample() {
let tape = std::env::temp_dir().join("dwarves-saga-absent-tape-test.jsonl");
let _ = std::fs::remove_file(&tape);
let pane = saga_panel_at(&tape, "nordisk");
let st = pane.state_json();
// (a) + (b) + (c)
assert_eq!(
st["session_count"], 0,
"nothing imported ⇒ no sessions: {st}"
);
assert!(
st["active_session"].is_null(),
"…and nothing selected: {st}"
);
assert_eq!(
st["source"], "empty",
"`sample` is not a source any more: {st}"
);
assert_eq!(st["tape_empty"]["empty"], true);
assert_eq!(
st["tape_empty"]["feed"], TAPE_FEED,
"the empty state NAMES the tape feed"
);
assert_eq!(st["tape_empty"]["cause"], "no-rows");
// (d) the sample's invented facts appear NOWHERE.
let whole = st.to_string();
for gone in [
"njord-sample-1",
"/offert/7",
"missing org_nr",
"OffertForm",
] {
assert!(
!whole.contains(gone),
"the deleted sample's `{gone}` is gone: {whole}"
);
}
}
/// ROBOT SCENARIO (TASK #42 host) — the **Saga tape-recorder pane** driven over a
/// warehouse-SHAPED store (`Box<dyn SagaTapeStore>`), the SAME trait nornir's
/// live-Mimir `WarehouseTapeStore` implements. Seed two sessions into an in-memory
/// [`MemTapeStore`] via `append_events` (no file, no warehouse), inject it into the
/// pane via [`saga_panel_from_store`], then assert `list_sessions` surfaces them
/// **painful-first** in `state_json` and that selecting one loads its events into
/// the reused `Player`. This is the fat/thin injection seam under test — swap the
/// `MemTapeStore` for a `WarehouseTapeStore` and the pane is unchanged.
#[test]
fn robot_saga_pane_over_injected_store_lists_painful_first_and_loads_player() {
// (1) Seed a warehouse-shaped store through the trait (append_events), the
// way a recorder drains into Mimir — a stuck session + a calm one.
let store = MemTapeStore::new();
store.append_events(&sample_tape()).unwrap();
// (2) Inject the store into the pane (the fat/thin seam). from_tape=true → a
// real recorded source (not the sample fallback), asserted via `source`.
let mut pane: Box<dyn Panel> = saga_panel_from_store(Box::new(store), "njord", true);
let st = pane.state_json();
// list_sessions surfaced both, painful-first: the stuck njord-sample-1 on top.
let sessions = st["sessions"].as_array().unwrap();
let list_ok = st["component"] == "SagaView"
&& st["source"] == "tape"
&& st["session_count"].as_u64().unwrap_or(0) >= 2
&& sessions[0]["session_id"] == "njord-sample-1"
&& sessions[0]["stuck"] == true
&& sessions[0]["frustration_score"].as_u64().unwrap_or(0) > 0
// painful-first is a real ordering: the calm session sits BELOW the stuck one.
&& sessions[0]["frustration_score"].as_u64().unwrap_or(0)
>= sessions[1]["frustration_score"].as_u64().unwrap_or(0);
// Selecting the top session loaded its events into the Player (read_session →
// saga_to_ui → Player): the transport has steps + one RED diverged marker.
let auto_ok = st["active_session"] == "njord-sample-1"
&& st["len"].as_u64().unwrap_or(0) >= 3
&& st["steps"].as_array().unwrap().len() >= 3
&& st["diverged_count"].as_u64().unwrap_or(0) >= 1;
// Select the CALM session over the trait: its events load, with no RED step.
pane.update_json(r#"{"select_session":"njord-sample-2"}"#);
let st2 = pane.state_json();
let select_ok = st2["active_session"] == "njord-sample-2"
&& st2["len"].as_u64().unwrap_or(0) >= 1
&& st2["diverged_count"].as_u64().unwrap_or(99) == 0;
// Scrub the calm session forward one step (drives the reused Player).
pane.update_json(r#"{"step":1}"#);
let step_ok = pane.state_json()["pos"].as_u64().unwrap_or(0) == 1;
let drivable = list_ok && auto_ok && select_ok && step_ok;
assert!(
drivable,
"the Saga pane over an injected SagaTapeStore lists painful-first + loads the Player: {st}"
);
facett_core::testmatrix::emit(
"dwarves-embed::sagaview",
"saga_pane_over_injected_store_lists_and_loads_player",
drivable,
"Saga tape-recorder pane (TASK #42 host): consume the shared nornir_saga::SagaTapeStore trait object — seed sessions into an in-memory MemTapeStore (the same trait nornir's live-Mimir WarehouseTapeStore implements) → inject via saga_panel_from_store → list_sessions surfaces them painful-first (state_json ordering) → selecting one loads read_session events into the reused nornir_saga::ui::Player. The fat/thin warehouse seam: swap MemTapeStore for WarehouseTapeStore, pane unchanged",
);
}
/// ROBOT SCENARIO (TASK #42) — the **Saga tape-recorder pane** driven over the
/// remote seam (`Panel::state_json` / `update_json`), the SAME channel a
/// `nornir-robotui` scenario / thin client use. Import a fixture `.jsonl` tape →
/// the sessions list populates → select → scrub (step/seek) → assert the stage
/// shows the RIGHT frame's `state_json` + the RED diverged (stuck) marker. Emits
/// a `nornir-testmatrix` cell (reuse edda).
#[test]
fn robot_saga_pane_imports_scrubs_and_marks_the_stuck_step() {
let ws = "dwarves-saga-robot-ws";
let tape = workspace_saga_tape_path(ws);
let _ = std::fs::remove_file(&tape);
// (1) Land a fixture tape into the workspace tape (the import relay's output),
// through the file-backed store (REUSE — no raw JsonFileSink write).
FileTapeStore::new(tape.clone())
.append_events(&sample_tape())
.unwrap();
// (2) Build the pane from that tape (a real import, not the sample fallback).
let mut pane: Box<dyn Panel> = saga_panel_at(&tape, ws);
let st = pane.state_json();
let source_ok = st["component"] == "SagaView" && st["source"] == "tape";
// The sessions rail populated, painful-first: the stuck njord-sample-1 on top.
let sessions = st["sessions"].as_array().unwrap();
let rail_ok = st["session_count"].as_u64().unwrap_or(0) >= 2
&& sessions[0]["session_id"] == "njord-sample-1"
&& sessions[0]["stuck"] == true
&& sessions[0]["frustration_score"].as_u64().unwrap_or(0) > 0;
// Auto-selected the top session; its plan has the actionable steps + one RED.
let auto_ok = st["active_session"] == "njord-sample-1"
&& st["steps"].as_array().unwrap().len() >= 3
&& st["diverged_count"].as_u64().unwrap_or(0) >= 1;
// The diverged step is the nav to the stuck route /offert/7.
let steps = st["steps"].as_array().unwrap();
let red_step = steps.iter().find(|s| s["diverged"] == true).unwrap();
let marker_ok = red_step["route"] == "/offert/7" && red_step["kind"] == "navigate";
// (3) SCRUB with seek: land ON the stuck step (seek to just after it) and
// assert the stage shows THAT frame's captured state_json + the RED flag.
let red_index = red_step["index"].as_u64().unwrap() as usize;
pane.update_json(&serde_json::json!({ "seek": red_index + 1 }).to_string());
let st = pane.state_json();
let scrub_ok = st["pos"].as_u64().unwrap() as usize == red_index + 1
&& st["current_index"].as_u64().unwrap() as usize == red_index
&& st["current_diverged"] == true
&& st["stage_state"].as_str().unwrap().contains("/offert/7");
// (4) STEP forward one and assert the transport advanced (pos moved).
let pos_before = st["pos"].as_u64().unwrap();
pane.update_json(r#"{"step":1}"#);
let st = pane.state_json();
let step_ok = st["pos"].as_u64().unwrap() == pos_before + 1;
// (5) SEEK to a non-diverged earlier frame → the RED flag clears (the marker
// tracks the frame, not the whole session): RED-when-broken proof.
pane.update_json(r#"{"seek":1}"#);
let st = pane.state_json();
let clears_ok =
st["current_diverged"] == false && st["current_index"].as_u64().unwrap() == 0;
// (6) Re-bind on the workspace broadcast (the SAME `{"set_workspace":…}` every
// tab gets): a workspace with NO tape rebinds to zero sessions and the NAMED
// empty state over its tape path. It used to rebind to the njord sample — so
// switching workspace could hand you another workspace's journey as your own.
pane.update_json(r#"{"set_workspace":"dwarves-saga-empty-ws"}"#);
let st = pane.state_json();
let rebind_ok = st["selected_workspace"] == "dwarves-saga-empty-ws"
&& st["source"] == "empty"
&& st["session_count"] == 0
&& st["tape_empty"]["feed"] == TAPE_FEED;
let _ = std::fs::remove_file(&tape);
let drivable = source_ok
&& rail_ok
&& auto_ok
&& marker_ok
&& scrub_ok
&& step_ok
&& clears_ok
&& rebind_ok;
assert!(
drivable,
"the Saga pane imports a tape, lists sessions, scrubs, and marks the stuck step: {st}"
);
facett_core::testmatrix::emit(
"dwarves-embed::sagaview",
"saga_pane_imports_scrubs_and_marks_stuck_step",
drivable,
"Saga tape-recorder pane (TASK #42): import a `.jsonl` tape → painful-first sessions rail (summarize_sessions) → select → scrub via the reused nornir_saga::ui::Player (saga_to_ui bridge) → stage shows the current frame's state_json + a RED diverged/stuck step (source_seq) — driven headless over Panel::state_json/update_json; re-binding to a workspace with no tape yields zero sessions + the NAMED empty state (the njord sample fallback was deleted 2026-09-10)",
);
}
/// The sample tape served through the SAME `SagaTapeStore` trait the shipped pane
/// uses — the explicit injection that replaced the deleted never-blank fallback.
///
/// These transport tests need A tape; what they must NOT do is get one for free
/// from a pane that was handed no feed. Injecting it here keeps every assertion
/// below intact while leaving `from_tape_path("/nonexistent")` free to mean what it
/// now means: no sessions, and a named empty state.
fn sample_pane() -> SagaView {
SagaView::from_store(
Box::new(MemTapeStore::from_events(sample_tape())),
"njord",
true,
)
}
/// A clean session has NO red steps — the RED marker is not a false-positive.
/// RED-when-broken: if the stuck detection painted every step, this reds.
#[test]
fn a_calm_session_has_no_diverged_steps() {
let mut pane = sample_pane();
// Select the calm second sample session.
pane.select("njord-sample-2");
let st = Facet::state_json(&pane);
assert_eq!(st["active_session"], "njord-sample-2");
assert_eq!(st["diverged_count"], 0, "a calm session has no stuck steps");
assert!(st["steps"]
.as_array()
.unwrap()
.iter()
.all(|s| s["diverged"] == false));
}
/// The transport is the reused Player: seek/step/speed drive it deterministically.
#[test]
fn transport_seek_and_speed_drive_the_reused_player() {
let mut pane = sample_pane();
pane.select("njord-sample-1");
// seek to the end.
let len = Facet::state_json(&pane)["len"].as_u64().unwrap();
Facet::update_json(&mut pane, &serde_json::json!({ "seek": len }).to_string());
assert_eq!(
Facet::state_json(&pane)["pos"].as_u64().unwrap(),
len,
"seek lands at the end"
);
// rewind.
Facet::update_json(&mut pane, r#"{"seek":0}"#);
assert_eq!(Facet::state_json(&pane)["pos"], 0);
assert!(
Facet::state_json(&pane)["current_index"].is_null(),
"no current step at the start"
);
// speed change is clamped + reflected.
Facet::update_json(&mut pane, r#"{"speed":2.0}"#);
assert_eq!(Facet::state_json(&pane)["speed"], 2.0);
}
}