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

nordisk/dwarves

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