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

nordisk/nornir

.nornir/viz-action-trail.md

N5 — viz action trail → warehouse (durable viz_actions sink)

LAW (persist-to-warehouse, stream not /tmp): everything durable streams into the warehouse; /tmp sinks are stopgaps / fast live-channels only.

What this is

The viz records every user-action — tab switch, button click, query, RPC call, error, lifecycle event — into its TOTAL action log (src/viz/action_log.rs). Before N5 that log had three sinks, all ephemeral:

  1. an in-memory ring buffer (last 256 entries) painted in the bottom overlay,
  2. a per-entry stderr line (nornir-viz ACTION …), and
  3. an appended /tmp file at $NORNIR_VIZ_ACTIONLOG (default /tmp/nornir_viz_actions.log) — greppable, externally observable.

The /tmp file was explicitly a stopgap. N5 promotes the action trail to a durable, queryable warehouse table — a fourth, always-on sink — and lets the viz read its own action history back from the warehouse.

The /tmp file stays. Per the LAW we write both: the file is the fast LOCAL live-channel (greppable, tail-able by other nornir processes), the warehouse is the DURABLE sink (survives the terminal, queryable from SQL, visible to other clients / past sessions).

The table — viz_actions

Schema: src/warehouse/iceberg_schema.rs :: viz_actions(). Registered + partitioned by session_id in src/warehouse/iceberg.rs (TABLE_VIZ_ACTIONS).

col type meaning
session_id string (required) one viz launch's trail; a fresh UUID minted per ActionLog. Partition key — a per-session read prunes to that launch's files.
seq long (required) per-session monotonic counter; the stable ordering key (Iceberg gives no scan order, so reads sort by (session_id, seq)). Mirrors the in-memory ring's seq exactly, so file ⇆ ring ⇆ warehouse agree.
ts_micros timestamptz (required) action time, µs since the Unix epoch (UTC).
kind string (required) category tag: LIFE | TAB | CLICK | QUERY | RPC | ERROR.
workspace string (required) active workspace at push time ("" when n/a, never null).
tab string (required) active tab at push time ("" when n/a, never null).
detail string (required) free-form action text (same text the /tmp file + ring carry).

Grain: one row per pushed action.

The writer / reader — src/warehouse/viz_actions.rs

Mirrors test_results.rs / release_events.rs:

Streaming in (append-as-you-go)

ActionLog::attach_warehouse(root) (called once in app.rs::build, local mode only — remote mode keeps file/stderr, the server is the durable side) spawns a background drain thread that owns one IcebergWarehouse for the log's lifetime and batches a burst of actions into one Iceberg snapshot. This is the same shape as the MCP telemetry writer (spawn_mcp_log_writer).

ActionLog::push(kind, detail) now, in addition to ring + stderr + file, does a non-blocking channel send of a VizActionRow to that thread — so the viz frame never waits on Iceberg I/O. The row is stamped with the current session_id, seq, ts_micros, and the active workspace / tab (kept current each frame by set_context, a single cheap mutex update next to the tab-switch log site). If the channel is gone (warehouse failed to open) the sink quietly disables itself; file/stderr keep working. Best-effort + non-fatal — a logging-row failure never disturbs the running viz.

The very first LIFE "action-log started" line is pushed in new(), before the sink is attached, so it lives only in the /tmp file + ring. The durable trail opens with the re-emitted LIFE "durable sink → warehouse viz_actions (session …)" marker.

Reading back

ActionLog::warehouse_recent_json(n) opens the warehouse read-only and returns this session's most-recent n actions as a JSON array. It's surfaced in state_json()["action_log"]:

"action_log": {
  "file": "/tmp/nornir_viz_actions.log",   // the live channel
  "count": 42,
  "recent": [ "HH:MM:SS.mmm [TAB] …", … ], // the in-memory ring
  "session_id": "f8057de9-…",              // N5 — this launch's id
  "warehouse_recent": [                    // N5 — read BACK from viz_actions
    { "session_id": "f8057de9-…", "seq": 7, "ts": "…Z",
      "kind": "ERROR", "workspace": "knut", "tab": "Release",
      "detail": "reload failed: …" }, …
  ]
}

warehouse_recent proves the trail is durable + queryable, not just a /tmp file — the same data is readable from DuckDB / PyIceberg:

SELECT seq, kind, tab, detail
FROM viz_actions
WHERE session_id = 'f8057de9-…'
ORDER BY seq;

ActionLog::flush() closes the channel and joins the drain thread (used by the inject-assert test for a deterministic, sleep-free flush; the live viz lets the thread run for the process lifetime).

Test (inject-assert, LAW #1)

tests/viz_action_log.rs:

Plus the warehouse round-trip unit test in src/warehouse/viz_actions.rs::tests (scope + ordering + limit + JSON).

Build / run

cargo build --features viz --bin nornir-ui
# on time 0.3.48: cargo update -p time --precise 0.3.47
cargo test --features viz --test viz_action_log