nornir-saga — live UI-movement viewer + replay (DESIGN)
Class:
design. What it is. TWO layers: (0) the make-it-work e2e loop — capture a journey through nornir's own egui viz → persist to thesaga_eventswarehouse → replay it as a robot test (this run's headline); and (1–9) theuiviewer/replay side (the earlier consumer/viewer/replay work). Spec:nornir-saga.md; how-to:saga-guide.md; zero-knowledge:saga-for-idiots.md. Njord's saga-emitter side is a separate, coordinated change — Njord is not touched.
0. Make-it-work e2e — the record → replay → verdict loop
The "make it work" ask, delivered: the whole loop now runs end-to-end through nornir's real egui viz, proven by one test (tests/saga_e2e.rs) that records a journey with real AccessKit header clicks, persists it, replays it GREEN against a fresh app, and goes RED the instant a recorded widget vanishes.
nornir viz (egui)
│ record(SagaEvent::nav/click.in_session(ctx)) ← capture adapter (feature `saga`)
▼
process-global capture buffer
│ ⏺ Record clears it · navigate · ⏹ Stop drains it (a SAGA_CAPTURE job)
▼
IcebergSagaSink → warehouse `saga_events`
│ read back
▼
session_replay_plan → ReplayPlan → plan_to_scenario → robot Scenario
│
▼
run vs a FRESH app → GREEN (widget present) / RED (widget gone) = the inject-assert LAW
The capture buffer (feature saga). record (record.rs:69): ON → PII-redact by default, stamp a monotonic seq + ts_micros, push into a process-global buffer; drain_saga_events (record.rs:84) takes + clears. OFF → #[inline] no-op + hard-empty drain (record.rs:78/:91) — release ships and pays nothing. Same gating discipline as nornir-testmatrix's functional_status.
The egui capture adapter — the one nav/click choke. src/viz/app.rs mints a stable per-window session id (saga_session_id, app.rs:471) so a window's whole journey is one session. saga_record_nav (app.rs:1592) fires at the tab-switch choke (app.rs:3024); its route is /tab/<Tab> and its component is the header's AccessKit label ([Tab::label]) — so a replay's ClickLabel(component) re-drives the exact widget. saga_record_click (app.rs:1606) is the public twin for any pane's on-click (e.g. reload, app.rs:2961). All #[cfg(feature = "saga")] — inlined away in a stock release.
⏺ Record / ⏹ Stop → the warehouse. The 🧪 Test tab's Saga Sessions pane (draw_saga_sessions, src/viz/test_tab.rs:800): ⏺ Record (saga_start_recording, test_tab.rs:917) clears the buffer and arms capture; ⏹ Stop (saga_stop_and_persist, test_tab.rs:931) drains the buffer, opens a kind::SAGA_CAPTURE (jobs.rs:169) job, writes the batch through IcebergSagaSink, and reloads the session list. The job's Drop guard fails the row on any early return — no zombie running job on a torn write.
The warehouse write/read (src/warehouse/saga_events.rs): append_saga_events (saga_events.rs:87, one partition per session), query_saga_events (saga_events.rs:154, sorted by (session_id, seq)), list_saga_sessions (saga_events.rs:302) — rolled up into SagaSessionSummary rows with a frustration score so rage-click / stuck sessions float to the top (summarize_sessions, saga_events.rs:242).
Replay-as-verdict. session_replay_plan (saga_events.rs:308) → a ReplayPlan; nornir-robotui's plan_to_scenario → a robot Scenario of ClickLabel gestures. Run against a fresh app: every recorded step must PASS while its widget exists, and remapping one step to a non-existent widget (plan_to_scenario_with) must produce a FAIL row, never a panic — a vanished widget is a real, caught regression.
The ui::Saga → SagaEvent bridge. A web/egui UI-movement recording (ui::Saga) flows into the SAME table + replay path: Saga::to_saga_events (ui/msg.rs:193) keeps only the actionable Nav/Click movements and maps them to SagaEvents; saga_from_ui (saga_events.rs:354) re-exports it host-side.
The nav-funnel path CLI. nornir saga path <session> (SagaOp::Path, src/bin/nornir.rs:428) reads a session's saga_events back and folds its nav stream into an ordered route NavFunnel via session_nav_funnel (saga_events.rs:320): route → sessions-reached, per-step drop-off, next-route transitions. An unknown session is an empty funnel, never an error. --json emits it as JSON. Sibling of the view/replay verbs (SagaOp, nornir.rs:411).
Feature flags. saga (capture ON both host + crate), viz (the ⏺/⏹ pane), web (the axum+SSE replay frontend), drive (real WebDriver). The e2e proof runs with cargo test --features "saga" --test saga_e2e. A stock nornir viz (no saga) still lists + replays sessions already in the warehouse; it just does not capture new ones.
1. What it is
A UI saga is the ordered stream of movements a user (or agent) makes through a web UI — the pointer travels, clicks fire, the page scrolls, focus jumps, the route changes, fields are typed into. nornir-saga::ui does three things over one recorded saga:
- LIVE view — fold the movement stream into a viewer model and render the motion as it happens.
- REPLAY — re-dispatch the recorded movements deterministically, time-scaled and seekable, so a viewer (terminal, or a browser page) re-animates the session.
- DRIVE the real page — dispatch the recorded pointer/click/nav movements at a live browser so the actual page moves.
Everything is demoable with a built-in synthetic saga — no live Njord, no real browser required.
2. ELM is the enabling invariant
A saga-enabled UI — a lone facett-core Elm component or a whole Njord-alike app — routes every input through the same Msg → update → view pipeline. So the saga IS the ordered Msg stream (± Model/state_json snapshots). Three consequences, all of which fall out of the one pipeline:
- READ — the saga is a
Msg+ state log. - LIVE — fold the
Msgstream into a viewerModel, render the motion. - REPLAY — re-dispatch the recorded
Msgs through an Elmupdate; becauseupdateis a pure function of(msg, model), the same saga always folds to the same model sequence — replay is deterministic, seekable, time-scalable.
The viewer here follows the facett-core Elm contract shape — one serializable ViewerModel, every input a ViewerMsg, mutation only in update, the observable state read back as state_json — but does not implement facett_core::Elm directly (that trait's view(&self, &mut egui::Ui) binds egui; the crate stays egui-free/lean). The viewer's render target is the web frontend and the CLI, both painting purely from state_json. A native facett_core::Elm impl (egui view + the headless harness) is a clean follow-on behind an optional facett feature.
3. DEV-ONLY capture — gone in a release build (feature saga)
The emit hooks a consuming app adds to feed a saga must not exist in a normal release build. This is enforced by the off-by-default saga feature:
- The one tap point. Because both a facett component and a full app share the Elm
updatechoke point, capture is a single call there:nornir_saga::ui::tap(&msg). It projects the app'sMsgto aUiMsgvia the app-implementedSagaMsgtrait and buffers the movement. No per-widget wiring — an app enables capture once at its dispatch. sagaON (cargo run --features saga) →tapstamps a monotonict_msand buffers;drain_saga(title)takes the buffer as aSaga.sagaOFF (cargo build --release, no feature) →tap/captureare#[inline]no-ops. The projection call is stripped too — zero code, zero extra deps. Capture is purestd, so neither the default nor thesagabuild pulls anything beyondserde/anyhow(verified withcargo tree).
A consuming app therefore takes nornir-saga as an optional dependency behind its own off-by-default saga feature (like knut's web/server) and adds the one tap line; dev captures, release ships clean.
The emit seam (ui::emit). The global tap/drain_saga is the zero-config path (one process-global buffer). When a host wants to stream movements live or hold its own buffer, it uses the pluggable Emitter<S> over the UiSink trait — the small, stable Njord-facing surface:
Emitter::tap(&msg)projects viaSagaMsg, stamps a monotonict_ms, and routes the movement to the chosen sink.emit_at/emit_at_with_stategive a deterministic (clock-free) path carrying an optional sourcestate_json.UiSinkimpls:VecSink(in-process capture →into_saga(title)),ChannelSink(LIVE — sends down anmpsc, paired with aChannelSourcefeeding a viewer thread),NullSink(drop). Object-safe (Box<dyn UiSink>).
The global tap is conceptually a process-global Emitter over an internal buffer; both obey the same SagaMsg projection and non-decreasing-t_ms invariant, so a saga captured either way is identical in shape. The seam is pure std (zero extra deps) and, like all of nornir-saga, is only linked when the host depends on the crate (its own saga feature) — release ships nothing.
4. The contract (ui::msg) — model-is-the-contract
UiMsg = // one Elm Msg a saga-enabled UI reacted to
| PointerMove { x, y }
| Click { x, y, target }
| Scroll { x, y }
| Focus { target }
| Nav { route }
| FieldEdit { target, value }
| Key { key }
| Note { text } // annotation; not a page gesture
TimedUiMsg = { t_ms, msg: UiMsg, state_json?: String } // ± source snapshot
Saga = { version, title, events: Vec<TimedUiMsg> } // t_ms non-decreasing
Tagged serde form ({"kind":"click",…}) — self-describing and additive. This is exactly what a saga-enabled Njord emits and what the viewer/replay/driver consume.
5. Viewer, source seam, replay engine
ui::viewer::Viewerfolds the stream intoViewerModel— cursor + motion trail + event log + route/focus/scroll + per-kind counts — exposed asstate_json(the robot assertion + render surface).updateis the only mutation path.ui::source::SagaSource— the pull seam.RecordedSource(aVec, the demo/replay path) andChannelSource(a livempscstream — where a saga-enabled Njord's SSE/socket feed lands for LIVE view) both implement it.ui::player::Player— the replay engine:step/seek(index)/seek_ms(t)/with_speed(x)/frames(). Deterministic (step==seek; same saga ⇒ identical frame sequence), seekable, and time-scaled — the player computesnext_delay_ms(the scaled inter-event gap) and the host (CLI loop or web SSE task) does the pacing, so the engine stays pure and testable.
6. WebReplayDriver — moving the real page
ui::driver::WebReplayDriver is the seam that dispatches recorded movements at a live page. A UiMsg maps to a DriveAction (from_msg); a driver turns each into a real DOM/WebDriver gesture. Implementations:
NoopDriver— dispatches nothing (safe default; replay still runs).MockDriver— records every dispatchedDriveAction(the test + demo surface;nornir saga replay --drive <url>prints these when no real browser is wired).WebDriverDriver(featuredrive, OFF by default) — a real browser over WebDriver (fantoccini), synthesizing each gesture as a DOM event viaexecute(JS) and navigation viagoto. Not exercised by the default test run (no browser in CI); point it at a running geckodriver/chromedriver to use it.
The cleanest real target — re-injecting Msgs into a live Elm Njord — needs a Njord-side Msg-injection hook and is a follow-on (see the spec); it is not built here.
7. Web frontend (feature web)
ui::web is an axum + SSE frontend (mirrors the knut-pipelines web viewer): GET / serves a self-contained page (inline SVG + vanilla JS, no CDN) that draws the cursor + trail + route + counters + log and re-animates them live; GET /saga.json serves the saga; GET /events streams state_json snapshots paced by the player's time-scaled delay so the page moves the way it was recorded. OFF by default; nornir saga replay --web (nornir built with --features saga-web) serves it.
8. CLI
nornir saga view <saga.json>— fold + print the finalstate_json(--framesprints the full deterministic sequence;--demouses the synthetic saga).nornir saga replay <saga.json>— print the deterministic frame sequence (JSONL);--speed Ntime-scales;--webserves the browser page (featuresaga-web);--drive <url>dispatches gestures at a real page viaWebReplayDriver(mock unless--features saga-drive+--webdriver).
9. Boundaries
Viewer + replay live in nornir-saga. The saga-enabled Njord emitter side (logging the Elm Msg stream and allowing Msg re-injection on replay) is Rickard's separate coordinated ask to another agent — do not touch Njord until he says so.