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

nordisk/nornir

.nornir/saga-design.md

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 the saga_events warehouse → replay it as a robot test (this run's headline); and (1–9) the ui viewer/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:

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:

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:

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:

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

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:

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

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.