commit 1ecf7ec5de0ff2a43f4e6d3705b77ec42de4953d
parent 8d8562ac53e1f3cf0f6758948d0a7b323eaf62a1
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Apr 2026 22:35:12 +0000
close human watch regression gaps
Diffstat:
| M | src/render/mod.rs | | | 318 | +++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------- |
| M | tests/job_rpc.rs | | | 61 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | tests/order.rs | | | 50 | ++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | tests/sync.rs | | | 32 | ++++++++++++++++++++++++++++++++ |
4 files changed, 349 insertions(+), 112 deletions(-)
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -1688,43 +1688,72 @@ fn render_job_get(stdout: &mut dyn Write, view: &JobGetView) -> Result<(), Runti
}
fn render_job_watch(stdout: &mut dyn Write, view: &JobWatchView) -> Result<(), RuntimeError> {
- write_context(stdout, format!("activity · watch {}", view.job_id).as_str())?;
- if view.frames.is_empty() {
- if let Some(reason) = &view.reason {
- writeln!(stdout, "{reason}")?;
- writeln!(stdout)?;
- } else {
- writeln!(stdout, "no frames collected")?;
- writeln!(stdout)?;
+ match view.state.as_str() {
+ "unconfigured" => {
+ writeln!(stdout, "Not ready yet")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "unavailable" => {
+ writeln!(stdout, "Unavailable right now")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "error" => {
+ writeln!(stdout, "Could not complete the command")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ _ => {
+ writeln!(stdout, "Watching job {}", view.job_id)?;
+ if view.frames.is_empty() {
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ } else {
+ for frame in &view.frames {
+ writeln!(stdout)?;
+ writeln!(
+ stdout,
+ "{}",
+ crate::runtime::job::format_clock(frame.observed_at_unix)
+ )?;
+ let mut rows = vec![
+ ("State", humanize_machine_label(frame.state.as_str())),
+ ("Summary", frame.summary.clone()),
+ ("Signer", humanize_machine_label(frame.signer.as_str())),
+ ];
+ push_row(&mut rows, "Session", frame.signer_session_id.clone());
+ if frame.terminal {
+ rows.push(("Terminal", "Yes".to_owned()));
+ }
+ render_field_rows(stdout, rows.as_slice())?;
+ }
+ }
+ if !view.actions.is_empty() {
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
}
- } else {
- let table = Table {
- headers: &[
- "frame", "time", "state", "signer", "session", "terminal", "summary",
- ],
- rows: view
- .frames
- .iter()
- .map(|frame| {
- vec![
- frame.sequence.to_string(),
- crate::runtime::job::format_clock(frame.observed_at_unix),
- frame.state.clone(),
- frame.signer.clone(),
- frame.signer_session_id.clone().unwrap_or_default(),
- yes_no(frame.terminal).to_owned(),
- frame.summary.clone(),
- ]
- })
- .collect(),
- };
- render_table(stdout, &table)?;
- writeln!(stdout)?;
}
- writeln!(stdout, "interval ms: {}", view.interval_ms)?;
- writeln!(stdout, "rpc url: {}", view.rpc_url)?;
- writeln!(stdout, "source: {}", view.source)?;
- render_actions(stdout, &view.actions)?;
Ok(())
}
@@ -2065,55 +2094,87 @@ fn render_order_submit_watch(
}
fn render_order_watch(stdout: &mut dyn Write, view: &OrderWatchView) -> Result<(), RuntimeError> {
- let context = match view.state.as_str() {
- "missing" => format!("order · {} watch missing", view.order_id),
- "not_submitted" => format!("order · {} not submitted", view.order_id),
- "unconfigured" => format!("order · {} watch unconfigured", view.order_id),
- "unavailable" => format!("order · {} watch unavailable", view.order_id),
- "error" => format!("order · {} watch error", view.order_id),
- "watching" => format!("order · {} watching", view.order_id),
- _ => format!("order · {} {}", view.order_id, view.state),
- };
- write_context(stdout, context.as_str())?;
-
- let interval = format!("{} ms", view.interval_ms);
- let mut rows = vec![("order id", view.order_id.clone()), ("interval", interval)];
- if let Some(job_id) = &view.job_id {
- rows.push(("job id", job_id.clone()));
- }
- render_owned_pairs(stdout, "watch", rows.as_slice())?;
- if !view.frames.is_empty() {
- let table = Table {
- headers: &[
- "frame", "time", "state", "signer", "session", "terminal", "summary",
- ],
- rows: view
- .frames
- .iter()
- .map(|frame| {
- vec![
- frame.sequence.to_string(),
- crate::runtime::job::format_clock(frame.observed_at_unix),
- frame.state.clone(),
- frame.signer_mode.clone(),
- frame.signer_session_id.clone().unwrap_or_default(),
- yes_no(frame.terminal).to_owned(),
- frame.summary.clone(),
- ]
- })
- .collect(),
- };
- render_table(stdout, &table)?;
- writeln!(stdout)?;
- }
- if let Some(workflow) = &view.workflow {
- render_order_workflow(stdout, workflow)?;
- }
- if let Some(reason) = &view.reason {
- writeln!(stdout, "reason: {reason}")?;
+ match view.state.as_str() {
+ "missing" => {
+ writeln!(stdout, "Not found")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "not_submitted" | "unconfigured" => {
+ writeln!(stdout, "Not ready yet")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "unavailable" => {
+ writeln!(stdout, "Unavailable right now")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "error" => {
+ writeln!(stdout, "Could not complete the command")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ _ => {
+ writeln!(stdout, "Watching order {}", view.order_id)?;
+ if view.frames.is_empty() {
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ } else {
+ for frame in &view.frames {
+ writeln!(stdout)?;
+ writeln!(
+ stdout,
+ "{}",
+ crate::runtime::job::format_clock(frame.observed_at_unix)
+ )?;
+ let mut rows = vec![
+ ("State", humanize_machine_label(frame.state.as_str())),
+ ("Summary", frame.summary.clone()),
+ ];
+ push_row(
+ &mut rows,
+ "Signer",
+ Some(humanize_machine_label(frame.signer_mode.as_str())),
+ );
+ push_row(&mut rows, "Session", frame.signer_session_id.clone());
+ if frame.terminal {
+ rows.push(("Terminal", "Yes".to_owned()));
+ }
+ render_field_rows(stdout, rows.as_slice())?;
+ }
+ }
+ if !view.actions.is_empty() {
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
}
- writeln!(stdout, "source: {}", view.source)?;
- render_actions(stdout, &view.actions)?;
Ok(())
}
@@ -3070,36 +3131,69 @@ fn render_market_update(stdout: &mut dyn Write, view: &SyncActionView) -> Result
}
fn render_sync_watch(stdout: &mut dyn Write, view: &SyncWatchView) -> Result<(), RuntimeError> {
- write_context(stdout, "activity · sync watch")?;
- if view.frames.is_empty() {
- writeln!(stdout, "no sync frames collected")?;
- writeln!(stdout)?;
- } else {
- let table = Table {
- headers: &["frame", "status", "freshness", "pending", "relays"],
- rows: view
- .frames
- .iter()
- .map(|frame| {
- vec![
- frame.sequence.to_string(),
- frame.state.clone(),
- frame.freshness.display.clone(),
- frame.queue.pending_count.to_string(),
- frame.relay_count.to_string(),
- ]
- })
- .collect(),
- };
- render_table(stdout, &table)?;
- writeln!(stdout)?;
- }
- writeln!(stdout, "interval ms: {}", view.interval_ms)?;
- if let Some(reason) = &view.reason {
- writeln!(stdout, "reason: {reason}")?;
+ match view.state.as_str() {
+ "unconfigured" => {
+ writeln!(stdout, "Not ready yet")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "unavailable" => {
+ writeln!(stdout, "Unavailable right now")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "error" => {
+ writeln!(stdout, "Could not complete the command")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ _ => {
+ writeln!(stdout, "Watching market sync")?;
+ if view.frames.is_empty() {
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ } else {
+ for frame in &view.frames {
+ writeln!(stdout)?;
+ writeln!(
+ stdout,
+ "{}",
+ crate::runtime::job::format_clock(frame.observed_at)
+ )?;
+ let rows = vec![
+ ("State", humanize_machine_label(frame.state.as_str())),
+ ("Relays", frame.relay_count.to_string()),
+ ("Updated", frame.freshness.display.clone()),
+ ("Queue", format!("{} pending", frame.queue.pending_count)),
+ ];
+ render_field_rows(stdout, rows.as_slice())?;
+ }
+ }
+ if !view.actions.is_empty() {
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
}
- writeln!(stdout, "source: {}", view.source)?;
- render_actions(stdout, &view.actions)?;
Ok(())
}
diff --git a/tests/job_rpc.rs b/tests/job_rpc.rs
@@ -582,3 +582,64 @@ fn job_watch_ndjson_emits_one_frame_per_poll_until_terminal() {
assert!(lines[1].contains("\"terminal\":true"));
assert!(lines[1].contains("\"signer_session_id\":\"session-1\""));
}
+
+#[test]
+fn job_watch_human_appends_snapshots_without_screen_clear() {
+ let _guard = job_rpc_test_guard();
+ let sequence = Arc::new(Mutex::new(0_usize));
+ let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new()));
+ let observed = Arc::clone(&requests);
+ let counter = Arc::clone(&sequence);
+ let server = MockRpcServer::start(move |method, auth_header| {
+ observed
+ .lock()
+ .expect("record requests")
+ .push(MockRpcRequest {
+ method: method.clone(),
+ auth_header,
+ });
+ match method.as_str() {
+ "bridge.job.status" => {
+ let mut count = counter.lock().expect("watch count");
+ *count += 1;
+ if *count == 1 {
+ MockRpcResponse::success(sample_job("job-123", "publishing", false, None))
+ } else {
+ MockRpcResponse::success(sample_job(
+ "job-123",
+ "published",
+ true,
+ Some(1_712_720_030),
+ ))
+ }
+ }
+ _ => MockRpcResponse::rpc_error(-32601, "method not found"),
+ }
+ });
+
+ let dir = tempdir().expect("tempdir");
+ let output = job_rpc_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "secret")
+ .args([
+ "job",
+ "watch",
+ "job-123",
+ "--frames",
+ "3",
+ "--interval-ms",
+ "5",
+ ])
+ .output()
+ .expect("run human job watch");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("Watching job job-123"));
+ assert!(stdout.contains("Publishing"));
+ assert!(stdout.contains("Published"));
+ assert!(stdout.contains("Summary"));
+ assert!(stdout.contains("Signer"));
+ assert!(!stdout.contains("activity ·"));
+ assert!(!stdout.contains("\u{1b}"));
+}
diff --git a/tests/order.rs b/tests/order.rs
@@ -988,6 +988,56 @@ job_id = "job_watch_01"
assert_eq!(json["frames"][0]["signer_session_id"], "sess_order_01");
assert_eq!(json["frames"][1]["signer_mode"], "nip46_session");
assert_eq!(json["frames"][1]["signer_session_id"], "sess_order_01");
+
+ let human_polls = Arc::new(Mutex::new(0usize));
+ let human_watch_polls = Arc::clone(&human_polls);
+ let human_server = MockRpcServer::start(move |body, _auth_header| {
+ match body["method"].as_str().unwrap_or_default() {
+ "bridge.job.status" => {
+ let mut count = human_watch_polls.lock().expect("watch polls lock");
+ *count += 1;
+ if *count == 1 {
+ MockRpcResponse::success(sample_bridge_job(
+ "job_watch_01",
+ "accepted",
+ false,
+ "sess_order_01",
+ ))
+ } else {
+ MockRpcResponse::success(sample_bridge_job(
+ "job_watch_01",
+ "completed",
+ true,
+ "sess_order_01",
+ ))
+ }
+ }
+ other => panic!("unexpected mock rpc method {other}"),
+ }
+ });
+
+ let human_output = order_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", human_server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "watch-token")
+ .args([
+ "order",
+ "watch",
+ "ord_AAAAAAAAAAAAAAAAAAAAAg",
+ "--frames",
+ "2",
+ "--interval-ms",
+ "1",
+ ])
+ .output()
+ .expect("run human order watch");
+ assert!(human_output.status.success());
+ let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("Watching order ord_AAAAAAAAAAAAAAAAAAAAAg"));
+ assert!(stdout.contains("Accepted"));
+ assert!(stdout.contains("Completed"));
+ assert!(stdout.contains("Summary"));
+ assert!(!stdout.contains("order ·"));
+ assert!(!stdout.contains("\u{1b}"));
}
#[test]
diff --git a/tests/sync.rs b/tests/sync.rs
@@ -165,3 +165,35 @@ fn sync_watch_ndjson_emits_one_frame_per_poll() {
assert!(lines[1].contains("\"sequence\":2"));
assert!(lines[1].contains("\"relay_count\":2"));
}
+
+#[test]
+fn sync_watch_human_appends_readable_snapshots_without_screen_clear() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+ let config_dir = dir.path().join(".radroots");
+ fs::create_dir_all(&config_dir).expect("workspace config dir");
+ fs::write(
+ config_dir.join("config.toml"),
+ "[relay]\nurls = [\"wss://relay.one\", \"wss://relay.two\"]\npublish_policy = \"any\"\n",
+ )
+ .expect("write workspace config");
+
+ let output = cli_command_in(dir.path())
+ .args(["sync", "watch", "--frames", "2", "--interval-ms", "1"])
+ .output()
+ .expect("run human sync watch");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("Watching market sync"));
+ assert!(stdout.contains("State"));
+ assert!(stdout.contains("Ready"));
+ assert!(stdout.contains("Relays"));
+ assert!(stdout.contains("Queue"));
+ assert!(!stdout.contains("activity ·"));
+ assert!(!stdout.contains("\u{1b}"));
+}