cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 1ecf7ec5de0ff2a43f4e6d3705b77ec42de4953d
parent 8d8562ac53e1f3cf0f6758948d0a7b323eaf62a1
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Apr 2026 22:35:12 +0000

close human watch regression gaps

Diffstat:
Msrc/render/mod.rs | 318+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mtests/job_rpc.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/order.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/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}")); +}