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

nordisk/nornir

.nornir/viz-introspection.md

Viz introspection — external state for debugging & robot testing

nornir-ui (the nornir viz GUI) is an egui app: normally you can only see its state on screen. Introspection makes the entire UI state observable from outside — no display, no screenshot OCR — so it can be debugged from a log and robot-tested headlessly.

Two channels, both always-on:

1. State dump — NORNIR_VIZ_STATE

Every frame (throttled to ~300 ms) the app serialises its full UI state to the JSON file named by $NORNIR_VIZ_STATE (default /tmp/nornir_viz_state.json). It is overwritten in place, so it always reflects what is on screen right now.

Schema:

{
  "source": "server http://127.0.0.1:7878",
  "connect_status": "connected http://127.0.0.1:7878: 5 workspace(s)",
  "tab": "Timeline",
  "all_tabs": ["Timeline","DepGraph","CallGraph","Funnel","TimeTravel",
               "LiveRun","Release","Knowledge","Warehouse","Mcp","Search",
               "Gates","Bench","Test","Leaderboard","Security"],
  "workspace_picker": { "options": ["holger","knut","njord","skade","znippy"],
                        "selected": "skade", "count": 5 },
  "repos": ["skade-katalog","skade"],
  "knowledge": { "configured_repos": ["skade-katalog","skade"], "scanning": false,
                 "scanned_repos": 2, "crates": 7,
                 "rows": [{"repo":"skade","krate":"skade","symbols":612,"calls":1840,
                           "files":31,"gates":4,"heat_30d":12}, …],
                 "repo_totals": [{"repo":"skade","ok":true,"symbols":612,"calls":1840,
                                  "features":4,"git_files":31}, …] },
  "release": { "source": "local /…/.nornir/warehouse", "error": null, "runs": 1,
               "selected_run": "run-b3-fixture",
               "rows": [{"component":"znippy","op":"test","phase":"end","status":"ok",
                         "line":"[znippy] test end ok (1.2s) — 3 passed"}, …],
               "component_status": { "znippy":"ok", "holger":"running" } },
  "about": { "client_version":"0.4.11", "mode":"local", "endpoint":"",
             "workspace":"skade", "warehouse":"/…/.nornir/warehouse",
             "server_version":null, "server_repo_count":null, "server_note":null },
  "timeline": { "releases": 42, "error": null },
  "dep_graph": { "deep": false, "node_count": 3,
                 "direct_edges": 3, "transitive_edges": 0, "collapsed": [],
                 "nodes": [{"repo":"holger","col":0,"row":0,"collapsed":false}, …],
                 "edges": [{"from":"holger","to":"knut","class":"direct","hops":1,
                            "via":["knut-core"]},
                           {"from":"A","to":"C","class":"transitive","hops":2,"via":[]}, …] },
  "search": { "form": { "query":"falkor", "corpus":"code", "sym_repo":"",
                        "sym_query":"build_timeline", "vec_repo":"", "vec_query":"",
                        "call_repo":"nornir", "call_name":"reload", "call_to":"" },
              "results": { "fulltext": {"ran":true,"ok":true,"count":12},
                           "symbols": {"ran":true,"ok":true,"count":3},
                           "vectors": {"ran":false}, "calls": {"ran":false},
                           "index_stats": {"ran":true,"ok":true,"total":840,
                                           "by_corpus":[["code","612"],["docs","228"]]} } },
  "gates": { "form": { "repo":"skade" },
             "report": {"ran":true,"ok":true,"passed":["build","clippy","test"],
                        "failed":[], "releasable":true},
             "trace": {"ran":false} },
  "bench": { "form": { "repo":"skade", "metric":"throughput_mb_s" }, "loaded":true,
             "ok":true, "metrics":["latency_ms","throughput_mb_s"], "point_count":9,
             "series":[{"date":"2026-06-01","version":"0.1.3","value":812.0}, …] },
  "warehouse": { "workspace":"skade", "tables":["bench_runs","releases","gates"],
                 "table_count":3, "selected":"releases", "error":null,
                 "chart": {"on":false, "value_col":null, "label_col":null},
                 "preview": {"loaded":true,"ok":true,
                             "columns":["release_id","sha","gate"], "rows":42} },
  "mcp": { "loaded":true, "total_calls":318, "capped":false,
           "rows":[{"tool":"repo_overview","calls":120,"errors":0,"avg_ms":4.1}, …] },
  "security": { "repos":["skade-katalog","skade"], "scanning":null, "busy":false,
                "result": {"ran":true,"ok":true,"repo":"skade","components":214,
                           "vuln_crates":0,"vulns":[],
                           "licenses":[["MIT",180],["Apache-2.0",30]],
                           "cache_hits":214,"cache_misses":0} },
  "warehouse_tables": ["bench_runs","releases","gates"],
  "placeholders": []
}

So state_json mirrors not just a tab's data but its control surface — what's in every box and dropdown right now — which is what a robot driver reads before it decides what to click.

2. Click trail — stderr

On every tab switch or workspace change the app logs one line to stderr:

nornir-viz CLICK: tab=Warehouse workspace=skade | connected http://127.0.0.1:7878: 5 workspace(s)

When the viz runs under the server's journal (or you launch it with stderr redirected to a file), this is the "what did the operator just click" trail.

3. Action trail — NORNIR_VIZ_ACTIONLOG + the 🐞 overlay

A third, richer channel records every interaction — tab switch, button click, query, RPC, error — with a millisecond stamp (src/viz/action_log.rs). It has three sinks: an in-memory ring (last 256), a per-entry nornir-viz ACTION … stderr line, and an appended file at $NORNIR_VIZ_ACTIONLOG (default /tmp/nornir_viz_actions.log), truncated once at launch. The toolbar's 🐞 log toggle opens a bottom panel that tails the file (not just this viz's ring), so it is the single shared source of truth, colour-coded by [TAG] ([ERROR] red · [BENCH] amber · [RPC] blue · [TAB] green · …).

Live bench-in-viz. Because the overlay tails the file, any other nornir process appending to the same $NORNIR_VIZ_ACTIONLOG shows up live — most usefully a running nornir bench, whose [BENCH] progress lines (see bencher.md) stream amber into the 🐞 panel as the suite runs:

# terminal A — viz on the shared action-log file, then toggle 🐞 log
NORNIR_VIZ_ACTIONLOG=/tmp/nornir_viz_actions.log nornir viz

# terminal B — same file; each bench: … line streams into the viz overlay
NORNIR_VIZ_ACTIONLOG=/tmp/nornir_viz_actions.log nornir bench run <repo>

Debugging (operator)

# terminal A — launch the viz pointed at the live server
export NORNIR_SERVER=http://127.0.0.1:7878
export NORNIR_SERVER_TOKEN=$(sudo sed -n 's/^NORNIR_SERVER_TOKEN=//p' /etc/nornir/nornir.env)
export NORNIR_VIZ_STATE=/tmp/nornir_viz_state.json
nornir viz 2> /tmp/nornir_viz.log

# terminal B — watch state + clicks as you click around
watch -n0.3 -- jq . /tmp/nornir_viz_state.json     # live UI state
tail -f /tmp/nornir_viz.log | grep CLICK            # the click trail

Robot / automated testing

The introspection is the assertion surface — drive the app (or just construct it headlessly) and assert on the JSON. Three levels:

  1. Headless data-layer matrix (no display). Construct the app state for a workspace (the same build() path the GUI uses — list_workspaces + load_timeline, no eframe::run) and assert on state_json() directly: for every workspace_*, assert workspace_picker.count > 0, placeholders excludes any release_demo*/demo text, all_tabs.len() == 16, and timeline.error == null (or a clean expected error). Parametrise across workspaces × tabs × {local,remote} → the test matrix.

  2. Headless button crash-hunt (no display, no GPU). tests/viz_buttons.rs drives the real egui UI through an egui_kittest::Harness — it walks the AccessKit tree, so no window and no GPU are needed — and clicks every clickable widget on every tab of every workspace (empty warehouses included; empty-state buttons must be safe too). Each click is wrapped in catch_unwind, so a panic is collected as {workspace, tab, button} and the hunt continues to the end before failing with a full listing. Where the data-layer matrix (1) exercises shape, this surfaces the bug that only fires when an operator actually clicks a live control — an out-of-bounds index, a non-char-boundary slice, a poisoned mutex, an unwrap() on missing data. Run: cargo test --features viz --test viz_buttons -- --nocapture. (The viz was bumped egui 0.28 → 0.33 alongside this; egui_kittest is the test harness that the new version makes available.)

  3. Live GUI smoke (with a display / Xvfb). Launch nornir viz with NORNIR_VIZ_STATE set, poll the JSON until connect_status is connected, then assert the picker + tables are populated. To exercise clicks, drive the window (fantoccini/nornir robot) and re-read the JSON after each click — the dump is the post-condition oracle.

Rule: any new select box / populated list / form field in the viz MUST be added to state_json() (in src/viz/app.rs, via each tab's own state_json()) so the matrix — and a robot driver — can see it. Introspection coverage is part of the feature, not an afterthought.

4. Control channel — NORNIR_VIZ_CMD (the drive-half)

The three channels above are read-only — they let an agent see the UI. The control channel lets it drive the UI. It's a tiny command file (the same shape as the state/action-log files): the driver writes one JSON command to $NORNIR_VIZ_CMD (default /tmp/nornir_viz_cmd.json); the running viz polls it once per frame (top of draw_ui → poll_control_channel), applies it, and deletes the file (consume-once, so a command fires exactly one time). The result then shows up in the next $NORNIR_VIZ_STATE dump. Implementation: src/viz/control.rs (the VizCommand type + cmd_path/take_pending/ write_command), applied by UrdrThreadsApp::apply_command.

Command schema (both fields optional):

{ "tab": "Test", "workspace": "korp" }

Why a file (not a socket): it matches the rest of the viz's external contract (state dump file, action-log file, trace file) — observable, greppable, no port to bind, no extra runtime, and it works the same headless or live. One command at a time is plenty for a click loop: the driver writes, the viz applies it next frame, the result lands in the state dump.

5. The robot-UI-tester loop — viz.state + viz.click MCP tools

nornir-mcp (src/bin/nornir-mcp.rs) wraps both halves as MCP tools, so a Claude-Code agent can see and drive the running viz with no display:

The two tools share the env contract with the viz — point both at the same $NORNIR_VIZ_STATE / $NORNIR_VIZ_CMD (the defaults /tmp/nornir_viz_state.json and /tmp/nornir_viz_cmd.json work out of the box).

Claude-Code example

# Terminal: launch the viz so it writes state + polls commands on the defaults
nornir viz            # nornir-ui; writes /tmp/nornir_viz_state.json,
                      # polls /tmp/nornir_viz_cmd.json each frame

# In Claude Code (nornir-mcp registered — see mcp-from-claude-code.md):
You:  What's the viz showing?
  → tool viz.state → { "tab":"Timeline", "workspace_picker":{"selected":"skade"}, … }

You:  Open the Test tab.
  → tool viz.click { "tab":"Test" }
  → { "command":{"tab":"Test"}, "consumed":true,
      "state": { "tab":"Test", "test":{ "runs":[…], "grid":{"repos":[…]} } } }
  ⇒ the agent SEES the Test pane's rendered rows it just navigated to.

You:  Now switch to the korp workspace and show me its warehouse tables.
  → tool viz.click { "tab":"Warehouse", "workspace":"korp" }
  → state.warehouse.tables = ["release_events","test_results", …]

This is the robot-UI-tester loop: viz.state (observe) → decide → viz.click (act) → viz.state (observe the result). Because every form field is in the state (channel 1) and every tab/workspace is drivable (channel 4), an agent can navigate and assert the viz end-to-end with no pixels.

Inject-and-assert proof (LAW #1): tests/viz_robot_loop.rs drives the loop through the real file channel — control::write_command (what viz.click writes) → one draw_ui frame (poll_control_channel consumes + applies) → state_json() (what viz.state returns) — and asserts the Test tab became active, its seeded run + grid rows came back, the command file was consumed once, and the drive landed in the action trail (viz.click tab → Test). A second test proves a workspace command re-selects the picker. The channel's own round-trip + consume-once + malformed-payload handling is unit-tested in src/viz/control.rs.

Live-window note. This pass drives a real running window via the file channel — nornir viz polls $NORNIR_VIZ_CMD every frame and the loop also keeps the egui frame ticking (request_repaint_after(250ms)) so a command lands within a frame or two even when the window is otherwise idle. The headless apply_command path is the same code the live poll calls, so the test exercises the exact production logic. A richer live-drive (setting an arbitrary text field, clicking a specific button by id, dragging a node) would need either (a) a widget-addressing scheme in the command (e.g. {set:{field:"search.query", value:"foo"}, button:"Search"}) wired into each tab's draw, or (b) injecting synthetic egui::Events into the RawInput against the AccessKit tree (the egui_kittest approach) from inside the running app — both larger than this tab/workspace pass and tracked as the next step.

Data-only diagram renderers (src/viz/diagram.rs)

Each diagram-bearing viz tab also has a headless, no-egui renderer that turns the same [Timeline] data model into exportable static SVG / Markdown — the data-only counterpart of the on-screen painting. They are compiled wherever viz::model is (the server build too), so docs, the Viz.Timeline RPC, and the CLI can render the viz diagrams without a display (CLI parity with the GUI). House style matches introspect::Graph::to_svg: graph diagrams render to static SVG via Typst (no Mermaid, no JS — they show verbatim in any web UI with no build step), tabular diagrams as plain GitHub-flavoured Markdown tables. Empty inputs return an italic placeholder rather than an empty table.

renderer viz tab shape
timeline_svg Timeline static SVG timeline, one lane per repo
lane_summary_table Timeline per-repo lane summary (releases, latest sha/gate/version)
depgraph_svg DepGraph static SVG graph LR, edges labelled by the via crates
snapshot_edge_list DepGraph plain cross-repo edge / adjacency list
gate_matrix_table Gates release × repo gate-verdict matrix (✓ / ✗)
release_versions_table Funnel published crate@version per release
bench_history_table Bench per-repo primary-metric trend, newest first (limit=N)
bench_compare_table TimeTravel latest bench metric across repos

Renderers must never open the warehouse — they take an already-built [Timeline] (the server holds the single warehouse lock), exactly like the bench_history doc-section renderer.

The Knowledge tab scans in the background (never on the UI thread)

The 🗺 Knowledge tab maps each workspace repo (one bubble per crate: symbols vs calls, sized by files, coloured by 30-day git heat). The scan is a real syn parse of every .rs under each repo plus a gix HEAD-history walk per repo — seconds of CPU on a multi-crate workspace.

Root cause of the historical ~5 s click stall: that scan ran synchronously on the UI thread inside KnowledgeState::draw() on the first frame after the tab was selected (and on every "↻ rescan"), so the whole egui frame — the entire window — froze for the wall-time of the slowest repo scan.

Fix (src/viz/knowledge.rs): the scan now runs on a dedicated std::thread::spawn (the LiveRun / Security-tab pattern), fanning out across repos with rayon inside the worker. draw() only:

  1. drains a finished scan from an Arc<Mutex<Option<…>>> handoff slot (lock poisoning is recovered, never panics the UI),
  2. arms a fresh scan (rescan_requested) on first entry, on the ↻ rescan button, and on a workspace switch, kicking off start_scan() only when no scan is already in flight, and
  3. paints "⏳ scanning N repo(s) …" + request_repaint() while the worker runs.

The UI thread never blocks. A repeat visit to the tab repaints from the cached scans instantly. Workspace switch (UrdrThreadsApp::switch_workspace) re-fetches the new workspace's member repos from the server and calls KnowledgeState::set_workspace(root, repos), which drops the stale scans, resets the status, and arms the background rescan — so the map re-scopes for the new workspace without a stall.

The tab's rendered rows live in state_json()["knowledge"] (above): rows are the exact per-crate bubbles painted (LAW #6), repo_totals the per-repo symbol/call/feature/git-file counts, and scanning is true while a background scan is in flight. tests/viz_knowledge.rs is the inject-and-assert proof: it writes two tiny known crates to a temp workspace, runs the scan, and asserts the exact symbol/call counts in state_json, then asserts a workspace switch resets