src/viz/control.rs
//! The viz **control channel** — the drive-half of the robot-UI-tester loop.
//!
//! The read-half ([`super::app::UrdrThreadsApp::state_json`] → `$NORNIR_VIZ_STATE`)
//! lets an agent see what's on screen. This is the write-half: a tiny
//! **command file** an external driver (the `viz.click` MCP tool) writes, that
//! the running viz polls **once per frame**, applies, and then deletes
//! (consume-once, so a command fires exactly one time).
//!
//! Why a file and not a socket: it's the same shape as the rest of the viz's
//! external contract (`$NORNIR_VIZ_STATE` dump file, `$NORNIR_VIZ_ACTIONLOG`
//! file, `$NORNIR_VIZ_TRACE` file) — observable, greppable, no extra runtime,
//! no port to bind, and it works the same headless or live. The driver writes;
//! the viz consumes. One command at a time is plenty for a click loop:
//! `viz.click` writes, the viz applies it next frame, and the result shows up in
//! the next `$NORNIR_VIZ_STATE` dump that `viz.state` reads back.
//!
//! Contract (the JSON written to `$NORNIR_VIZ_CMD`, default
//! `/tmp/nornir_viz_cmd.json`):
//!
//! ```json
//! { "tab": "Test", "workspace": "korp" }
//! ```
//!
//! Both fields are optional: set `tab` to switch the active tab (by its
//! `state_json()["tab"]` debug name — `Timeline`, `DepGraph`, …, `Security`),
//! `workspace` to switch the workspace picker. An unknown tab name is reported
//! back via `applied=false` + `error` in the action log; a known one applies and
//! the file is removed.
use serde::{Deserialize, Serialize};
/// One robot-drive command. All fields optional so a driver can switch just the
/// tab, just the workspace, or both in one command.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct VizCommand {
/// Tab to make active, by `state_json()["tab"]` debug name (e.g. `"Test"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab: Option<String>,
/// Workspace to select in the picker.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
/// App-wide facett palette to activate (by name, e.g. `"cyberpunk-neon"`);
/// `-`/`_`/spaces interchange. Drives the 🎨 palette picker (C8) headlessly so
/// the theming has CLI/robot parity, not GUI-only (project LAW).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub palette: Option<String>,
/// **R6** — set a named form field on the LIVE viz (CLI/robot parity with the
/// in-process `RobotSession::set_field`). The `name` is a stable viz field key
/// (e.g. `"funnel.intake"`, `"funnel.intake_is_error"`, `"funnel.plan_target"`,
/// `"funnel.demo_size"`), `value` the new value as a string (the viz parses it
/// per field type). Unknown names are reported `applied=false` in the action
/// log. See [`VizField`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub set_field: Option<VizField>,
/// **R6** — click a named button/action on the LIVE viz by a stable button key
/// (e.g. `"funnel.submit_intake"`, `"funnel.classify"`, `"funnel.generate_plan"`,
/// `"funnel.run_demo"`). The drive-half equivalent of `RobotSession::click_by_id`
/// for the running viz (where AccessKit node ids aren't stable across runs, a
/// named key is the durable address). Unknown keys → `applied=false`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub click_id: Option<String>,
/// **LAW4b** — request a self-screenshot: the viz fires
/// `ViewportCommand::Screenshot`, encodes the result as PNG, writes it to
/// `out_path` (when `Some`) **and** appends it to the warehouse
/// `robot_test_results` blob store. `None` path → warehouse-only. The path
/// is still returned in `state_json["screenshot"]["last_path"]`.
/// CLI parity: `nornir viz --screenshot <path>` sends this on launch and
/// exits after the file is written. Keyboard: Ctrl+Shift+S.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screenshot: Option<ScreenshotRequest>,
}
/// **LAW4b** — payload for a `VizCommand` self-screenshot request.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScreenshotRequest {
/// File path to write the PNG to, or `None` for warehouse-only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub out_path: Option<String>,
}
/// **R6** — one named-field set for the live control channel: a stable viz field
/// `name` + its new `value` (stringified; the viz parses bool/number/text per
/// the field). The robot/CLI parity counterpart of
/// [`RobotSession::set_field`](../../../nornir_robotui/struct.RobotSession.html#method.set_field).
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct VizField {
/// Stable field key (`"funnel.intake"`, …) the viz maps to its own state.
pub name: String,
/// New value as a string; the viz parses it for the field's type.
pub value: String,
}
/// Resolve the command-file path (`$NORNIR_VIZ_CMD`, default
/// `/tmp/nornir_viz_cmd.json`). Shared by the viz (reader) and the MCP
/// (writer) so both sides agree on the channel.
pub fn cmd_path() -> String {
std::env::var("NORNIR_VIZ_CMD").unwrap_or_else(|_| "/tmp/nornir_viz_cmd.json".to_string())
}
/// Reader side (the running viz): read **and consume** a pending command, if any.
/// Returns `None` when no command file exists. The file is removed once read so
/// the command fires exactly once. A malformed file is removed too (so a bad
/// write can't wedge the channel) and surfaced as an `Err`.
pub fn take_pending() -> Option<Result<VizCommand, String>> {
let path = cmd_path();
let raw = std::fs::read_to_string(&path).ok()?;
// Consume-once: remove before parsing so a bad payload can't loop forever.
let _ = std::fs::remove_file(&path);
if raw.trim().is_empty() {
return None;
}
Some(serde_json::from_str::<VizCommand>(&raw).map_err(|e| format!("bad viz command: {e}")))
}
/// Writer side (the `viz.click` MCP tool / a test driver): write a command for
/// the running viz to apply next frame. Overwrites any unconsumed command.
pub fn write_command(cmd: &VizCommand) -> std::io::Result<()> {
let path = cmd_path();
let s = serde_json::to_string_pretty(cmd).unwrap_or_else(|_| "{}".to_string());
std::fs::write(path, s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_write_take_consumes_once() {
let tmp = format!("/tmp/nornir_viz_cmd_test_{}.json", std::process::id());
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("NORNIR_VIZ_CMD", &tmp) };
let _ = std::fs::remove_file(&tmp);
// Nothing pending yet.
assert!(take_pending().is_none());
// Write a real command, read it back with real field values.
write_command(&VizCommand {
tab: Some("Test".into()),
workspace: Some("korp".into()),
palette: Some("cyberpunk-neon".into()),
set_field: Some(VizField {
name: "funnel.intake".into(),
value: "add dark mode".into(),
}),
click_id: Some("funnel.submit_intake".into()),
screenshot: Some(ScreenshotRequest {
out_path: Some("/tmp/test.png".into()),
}),
})
.unwrap();
let got = take_pending()
.expect("a command is pending")
.expect("parses");
assert_eq!(got.tab.as_deref(), Some("Test"));
assert_eq!(got.workspace.as_deref(), Some("korp"));
assert_eq!(got.palette.as_deref(), Some("cyberpunk-neon"));
assert_eq!(
got.set_field,
Some(VizField {
name: "funnel.intake".into(),
value: "add dark mode".into()
}),
"R6 set_field round-trips through the control channel"
);
assert_eq!(got.click_id.as_deref(), Some("funnel.submit_intake"));
assert_eq!(
got.screenshot,
Some(ScreenshotRequest {
out_path: Some("/tmp/test.png".into())
}),
"LAW4b screenshot request round-trips through the control channel"
);
// Consume-once: the second take sees nothing (the file was removed).
assert!(take_pending().is_none());
// A malformed payload is surfaced as an error, and consumed.
std::fs::write(&tmp, "{not json").unwrap();
let err = take_pending().expect("present").expect_err("malformed");
assert!(err.contains("bad viz command"), "got: {err}");
assert!(take_pending().is_none());
let _ = std::fs::remove_file(&tmp);
}
}