nornir-saga ↔ Njord — support gap analysis
Author: Ignalina ApS Date: 2026-07-18 Scope: READ-ONLY on Njord. What must nornir-saga (edda) implement so Njord consumes it as a leaf instead of a vendored copy (reuse-law: kill the twin).
Sides examined:
edda/crates/nornir-saga@main— the leaf, published0.1.4(crates.io).- Njord @
main(commit7cba7fe, read-only viagit show) — the saga consumer. The saga work (commits14f8674…7cba7fe) is merged to Njord main; there is no separate livefeat/saga-vendoredbranch, the vendored copy landed on main at466eae6and was removed again.
TL;DR — the headline is: the hard part is already done
-
API gap = ZERO. Every symbol Njord's saga code consumes exists in
nornir-saga 0.1.4with a matching signature, and the crate compiles clean under Njord's exact feature combos:cargo check -p nornir-saga --no-default-features --features saga→ OK (the app/capture build).cargo check -p nornir-saga --features drive→ OK (the dev/robot-test build).
-
The vendored twin is already killed. Njord
466eae6vendoredcrates/web/vendor/nornir-saga/; current main (7cba7fe) removed that directory and depends on the real leaf.git ls-tree 7cba7fe | grep -c vendor/nornir-saga→0. -
Njord already consumes the leaf, via a cross-repo path+version dep (
Njord/crates/web/Cargo.toml:61and:106):
nornir-saga = { path = "../../../edda/crates/nornir-saga", version = "0.1.4", default-features = false, optional = true }
# dev-dependencies:
nornir-saga = { path = "../../../edda/crates/nornir-saga", version = "0.1.4", features = ["drive"] }
So there is no blocking work for basic Njord support. What remains is (a) one packaging nit for clean-checkout consumption, and (b) reuse-law debt: generic web capture/replay glue Njord hand-rolls that should live once in the leaf. Ordered below by priority.
Symbol-by-symbol verification (Njord call → leaf definition)
| Njord use (file:line) | nornir-saga def (file:line) | Status |
SessionCtx::new(sid,user,app,ver,git_sha) — web/src/saga.rs:26 |
event.rs:372 (5 args) |
OK |
SagaEvent::nav(route,component,action,state) — web/src/components/saga_capture.rs:50 |
event.rs:126 (4 args) |
OK |
SagaEvent::click(route,component,action,state) — saga_capture.rs:78 |
event.rs:137 (4 args) |
OK |
SagaEvent::error(component,detail) — web/src/saga.rs:89 |
event.rs:157 (2 args) |
OK |
SagaEvent::in_session(&ctx) — web/src/saga.rs:90 |
event.rs:178 |
OK |
ingest(events,&ctx) -> usize — web/src/saga.rs:45 |
record.rs:139 |
OK |
MAX_INGEST_BATCH — web/src/saga.rs:40,46 |
record.rs:21 |
OK |
record(event) — web/src/saga.rs:88 |
record.rs:110 |
OK |
flush_saga_events(&sink) -> Result<usize> — web/src/saga.rs:65 |
record.rs:173 |
OK |
JsonFileSink::new(path) — web/src/saga.rs:60, tests/saga_robot.rs:294 |
sink.rs:50 |
OK |
JsonFileSink::read_all() -> Result<Vec<SagaEvent>> — tests/saga_robot.rs:295 |
sink.rs:60 |
OK |
ReplayPlan::all_sessions(&events) — tests/saga_robot.rs:297 |
replay.rs:122 |
OK |
ReplayPlan::is_replayable — tests/saga_robot.rs:298 |
replay.rs:144 |
OK |
plan.steps — tests/saga_robot.rs:351 |
replay.rs:79 (pub) |
OK |
step.kind.as_str() / step.action / step.route / step.source_seq — tests/saga_robot.rs:401-406 |
replay.rs:10,17,12,24 (pub) + StepKind::as_str replay.rs:41 |
OK |
ui::driver::DriveAction — tests/saga_robot.rs:24 |
ui/driver.rs:35, re-exported ui/mod.rs:58 |
OK |
DriveAction::from_step(step) — tests/saga_robot.rs:352 |
ui/driver.rs:89 |
OK |
DriveAction::ClickAt { target, .. } — tests/saga_robot.rs:354,452 |
ui/driver.rs:39 (ClickAt { x, y, target }) |
OK |
DriveAction::Navigate { route } — tests/saga_robot.rs:448 |
ui/driver.rs:44 |
OK |
Nothing missing, nothing mis-shaped. (Njord's yvra_web::functional_status is Njord's own matrix-emit wrapper, not a saga symbol — see G3.)
Remaining work, by priority
P1 — G1: reusable web-client capture adapter (reuse-law)
Gap. nornir-saga's capture side is egui-only: ui::tap + the emit seam (ui/capture.rs, ui/emit.rs) consume an egui/Elm Msg stream. There is no browser/wasm capture adapter. So Njord hand-writes the entire web client tap in Njord/crates/web/src/components/saga_capture.rs (83 lines):
- stable per-tab
session_id()(saga_capture.rs:18-30), current_path()fromwindow.location(:33-37),- fire-and-forget batched POST of a
SagaEvent(:40-46), emit_nav(path)→SagaEvent::nav(path,"Router","navigate","{}")(:49-51),- delegated document
clicklistener walking to the nearest[data-saga]ancestor and recordingSagaEvent::click(path, "[data-saga=\"…\"]", …)(install_click_listener,:58-82).
Every future nornir-saga web consumer rewrites this verbatim. It is generic.
Implement in nornir-saga (new module src/ui/web_capture.rs, behind a new web-capture feature gating web-sys/wasm-bindgen/js-sys):
session_id()/current_path()helpers.emit_nav(path)andinstall_click_listener(post_fn)wherepost_fnis a caller-suppliedFn(Vec<SagaEvent>)(the consumer owns the transport — a Leptosserver_fn,fetch, whatever). The[data-saga]locator convention is already documented onDriveAction::from_step(ui/driver.rs:89doc), so standardising the capture that produces it belongs here.
Njord then deletes saga_capture.rs and calls nornir_saga::ui::web_capture::{emit_nav, install_click_listener}.
P1 — G2: reusable axum server ingest glue (reuse-law)
Gap. Njord/crates/web/src/saga.rs hand-rolls two generic server pieces any axum saga consumer needs:
spawn_flush()(saga.rs:57-72) — atokio::interval(15s)loop callingflush_saga_events(&JsonFileSink::new($SAGA_LOG_PATH)).error_layer()(saga.rs:81-93) — axum middleware recordingSagaEvent::error("http", "<code> <path>")on any 5xx.
Implement in nornir-saga (extend the existing web feature, or a new axum-capture feature; the crate already carries axum+tokio behind web, see Cargo.toml):
pub fn spawn_flush(sink: impl SagaSink + 'static, period: Duration)— the crash-safe periodic flush loop.pub async fn error_layer(req, next) -> Response— the 5xx→error middleware (parameterise the session label).
Njord keeps only its app-specific session_ctx() + the saga_ingest server_fn wrapper (which is genuinely Njord routing) and consumes the two helpers.
P2 — G3: the web replay driver seam is too thin for production
Gap. WebReplayDriver (ui/driver.rs:102-114) is open / dispatch / finish only. It can dispatch a Navigate/ClickAt but cannot:
- inject caller-supplied field values at a step (email/password/verification code/orgnr are PII-redacted OUT of a saga by design, so the replay must supply them keyed on the step's target),
- query the DOM or assert an outcome (URL landed, element count),
- handle per-step pacing (let Leptos settle before the next locate).
Because of this the real Njord robot test bypasses WebDriverDriver entirely: tests/saga_robot.rs imports only ReplayPlan + DriveAction + JsonFileSink and hand-drives fantoccini, doing field injection keyed on target (saga_robot.rs:354-388), pacing (:394-398), and assertions via yvra_web::functional_status (:399-407). The Cargo comment says it outright (Njord/crates/web/Cargo.toml:102-105): "WebDriverDriver has no DOM-query/assert surface". So WebDriverDriver/drive_plan/drive_saga are a demo path, not the one a real consumer uses.
Implement in nornir-saga — make the driver seam production-usable so a consumer stops re-hand-driving the browser. Recommended shape: a ReplayHarness (feature drive) around drive_plan that adds two callback hooks the current trait lacks —
- an inject hook
Fn(&ReplayStep) -> Vec<(selector, value)>called before aClickAt(supplies the redacted-out values), and - a verify hook
Fn(&ReplayStep, &StepOutcome) -> bool(per-step assertion), plus optional inter-step delay. Emit the per-stepfunctional_statusrow from inside the harness when thetestmatrixfeature is on (that is exactly whatui::Player::verifyalready does for the egui side —player.rs), so the web side gets the same "every step = a matrix row" behaviour for free instead of each consumer calling its ownfunctional_status.
This is the one item that would let Njord's saga_robot.rs run() shrink to a harness call with two closures.
P3 — G4: registry-leaf, drop the cross-repo path dep (Njord-side, noted only)
Njord depends via path = "../../../edda/crates/nornir-saga" (Cargo.toml:61,106), which requires an edda checkout as a sibling directory — fine locally, breaks a clean/CI checkout that doesn't vendor edda next to Njord. nornir-saga 0.1.4 is published on crates.io (verified: default_version 0.1.4), so the leaf is registry-ready. The fix is a Njord-side edit (off-limits here, read-only): drop path =, keep version = "0.1.4", so it resolves from the registry. No nornir-saga work required — recorded for completeness.
Implementation plan (nornir-saga side, ordered)
- G1
web-capturefeature —src/ui/web_capture.rs:session_id,current_path,emit_nav,install_click_listener(post_fn); gateweb-sys/js-sys/wasm-bindgen. Port from Njordsaga_capture.rs. Bump to0.1.5, publish. Njord deletes its copy and consumes. - G2 axum glue — under the existing
webfeature:spawn_flush(sink, period)+error_layer. Port from Njordsaga.rs:57-93. Same publish. - G3
ReplayHarness(featuredrive) — inject-hook + verify-hook + pacing arounddrive_plan, emittingfunctional_statuswhentestmatrixis on. This is the largest piece; land after G1/G2. Prove it by rewriting the crate's own driver test to the harness (red-when-broken), then Njord adopts. - G4 — Njord-side only (drop
path=); do NOT touch Njord here. Note it in the Njord PLAN when the registry consumption is wanted.
Each step is additive (new feature / new fn), so no existing consumer breaks; the lean default build stays dep-free. G1+G2 delete ~120 lines of hand-rolled generic glue from Njord (reuse-law). G3 deletes the bespoke fantoccini drive loop.
Evidence log
- Njord vendored copy present at
466eae6(crates/web/vendor/nornir-saga/), absent at7cba7femain (git ls-tree 7cba7fe | grep -c vendor/nornir-saga→ 0). - Njord main deps:
git show 7cba7fe:crates/web/Cargo.tomllines 56–106. - Compile parity:
cargo check -p nornir-saga --no-default-features --features sagaand… --features drivebothFinishedclean against0.1.4. - Publish: crates.io
nornir-sagadefault_version = 0.1.4(5 versions).