N5 — viz action trail → warehouse (durable viz_actions sink)
LAW (persist-to-warehouse, stream not
/tmp): everything durable streams into the warehouse;/tmpsinks 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:
- an in-memory ring buffer (last 256 entries) painted in the bottom overlay,
- a per-entry stderr line (
nornir-viz ACTION …), and - an appended
/tmpfile 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:
VizActionRow— the typed row.append_viz_actions(wh, rows)/append_viz_actions_blocking(..)— append a batch as one Iceberg snapshot. Because the table is partitioned bysession_id, each append must be single-partition — which it always is, since a viz session only ever appends its own session's rows.ActionSelector { Session(id), All }+query_viz_actions(wh, sel, limit)/query_viz_actions_blocking(..)— scan, scope, sort by(session_id, seq), and (withlimit = Some(n)) keep the most-recent N — the "recent history" read-back view.rows_to_json(rows)— stable JSON array forstate_json(no serde derive).
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 innew(), before the sink is attached, so it lives only in the/tmpfile + ring. The durable trail opens with the re-emittedLIFE"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:
action_log_streams_into_warehouse_and_reads_back— attach a real on-disk warehouse, push a scripted trail (TAB/CLICK/RPC/QUERY/ERROR) with realworkspace/tabcontext,flush(), then assert the exact rows landed inviz_actions(count, strictly-increasingseq, per-rowkind/tab/detail/session_id, monotonicts) AND that the log's ownwarehouse_recent_jsonread-back returns them as parseable JSON. Real input, real output asserted — not "didn't panic".tmp_file_remains_the_live_channel_alongside_the_warehouse— the/tmpfile still carries the same trail (LAW: write both).
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