commit eb2f23e69651ad16fb6550bb31de6d3de508c51e
parent 4a6618dc1528de5f09cd55434b9ad6524df6579a
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Apr 2026 21:53:31 +0000
implement human first order aliases and watch flow
Diffstat:
7 files changed, 480 insertions(+), 116 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -141,6 +141,7 @@ Examples:
radroots order create --listing sf-tomatoes --bin bin-1 --qty 2
radroots order view ord_demo
radroots order list
+ radroots order submit ord_demo --watch
Compatibility aliases: new, get, ls.
";
@@ -1106,6 +1107,8 @@ pub struct OrderNewArgs {
#[derive(Debug, Clone, Args)]
pub struct OrderSubmitArgs {
pub key: String,
+ #[arg(long, action = ArgAction::SetTrue)]
+ pub watch: bool,
#[arg(long)]
pub idempotency_key: Option<String>,
#[arg(long = "signer-session-id")]
@@ -2047,6 +2050,15 @@ mod tests {
_ => panic!("unexpected command variant"),
}
+ let order_create = CliArgs::parse_from(["radroots", "order", "create"]);
+ match order_create.command {
+ Command::Order(args) => match args.command {
+ OrderCommand::New(_) => {}
+ _ => panic!("unexpected order subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
let order_get = CliArgs::parse_from(["radroots", "order", "get", "ord_demo"]);
match order_get.command {
Command::Order(args) => match args.command {
@@ -2056,6 +2068,15 @@ mod tests {
_ => panic!("unexpected command variant"),
}
+ let order_view = CliArgs::parse_from(["radroots", "order", "view", "ord_demo"]);
+ match order_view.command {
+ Command::Order(args) => match args.command {
+ OrderCommand::Get(key) => assert_eq!(key.key, "ord_demo"),
+ _ => panic!("unexpected order subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
let order_ls = CliArgs::parse_from(["radroots", "order", "ls"]);
match order_ls.command {
Command::Order(args) => match args.command {
@@ -2065,11 +2086,21 @@ mod tests {
_ => panic!("unexpected command variant"),
}
+ let order_list = CliArgs::parse_from(["radroots", "order", "list"]);
+ match order_list.command {
+ Command::Order(args) => match args.command {
+ OrderCommand::Ls => {}
+ _ => panic!("unexpected order subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
let order_submit = CliArgs::parse_from([
"radroots",
"order",
"submit",
"ord_demo",
+ "--watch",
"--idempotency-key",
"submit-1",
"--signer-session-id",
@@ -2079,6 +2110,7 @@ mod tests {
Command::Order(args) => match args.command {
OrderCommand::Submit(submit) => {
assert_eq!(submit.key, "ord_demo");
+ assert!(submit.watch);
assert_eq!(submit.idempotency_key.as_deref(), Some("submit-1"));
assert_eq!(submit.signer_session_id.as_deref(), Some("sess_456"));
}
@@ -2193,6 +2225,17 @@ mod tests {
assert_eq!(sell_add.command.display_name(), "sell add");
assert!(!sell_add.command.supports_dry_run());
+ let order_create = CliArgs::parse_from(["radroots", "order", "create"]);
+ assert_eq!(order_create.command.display_name(), "order create");
+ assert!(!order_create.command.supports_dry_run());
+
+ let order_view = CliArgs::parse_from(["radroots", "order", "view", "ord_demo"]);
+ assert_eq!(order_view.command.display_name(), "order view");
+ assert!(order_view.command.supports_dry_run());
+
+ let order_list = CliArgs::parse_from(["radroots", "order", "list"]);
+ assert_eq!(order_list.command.display_name(), "order list");
+ assert!(order_list.command.supports_dry_run());
let order_watch = CliArgs::parse_from(["radroots", "order", "watch", "ord_demo"]);
assert!(
order_watch
diff --git a/src/commands/order.rs b/src/commands/order.rs
@@ -1,134 +1,148 @@
use crate::cli::{OrderNewArgs, OrderSubmitArgs, OrderWatchArgs, RecordKeyArgs};
-use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView};
+use crate::domain::runtime::{
+ CommandDisposition, CommandOutput, CommandView, OrderSubmitView, OrderSubmitWatchView,
+};
use crate::runtime::RuntimeError;
-use crate::runtime::config::RuntimeConfig;
+use crate::runtime::config::{
+ CapabilityBindingTargetKind, OutputFormat, RuntimeConfig, WRITE_PLANE_TRADE_JSONRPC_CAPABILITY,
+};
pub fn new(config: &RuntimeConfig, args: &OrderNewArgs) -> Result<CommandOutput, RuntimeError> {
- let view = crate::runtime::order::scaffold(config, args)?;
- Ok(match view.disposition() {
- CommandDisposition::Success => CommandOutput::success(CommandView::OrderNew(view)),
- CommandDisposition::Unconfigured => {
- CommandOutput::unconfigured(CommandView::OrderNew(view))
- }
- CommandDisposition::ExternalUnavailable => {
- CommandOutput::external_unavailable(CommandView::OrderNew(view))
- }
- CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderNew(view)),
- CommandDisposition::InternalError => {
- CommandOutput::internal_error(CommandView::OrderNew(view))
- }
- })
+ let mut view = crate::runtime::order::scaffold(config, args)?;
+ rewrite_order_actions(&mut view.actions);
+ Ok(command_output(
+ view.disposition(),
+ CommandView::OrderNew(view),
+ ))
}
pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput, RuntimeError> {
- let view = crate::runtime::order::get(config, args)?;
- Ok(match view.disposition() {
- CommandDisposition::Success => CommandOutput::success(CommandView::OrderGet(view)),
- CommandDisposition::Unconfigured => {
- CommandOutput::unconfigured(CommandView::OrderGet(view))
- }
- CommandDisposition::ExternalUnavailable => {
- CommandOutput::external_unavailable(CommandView::OrderGet(view))
- }
- CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderGet(view)),
- CommandDisposition::InternalError => {
- CommandOutput::internal_error(CommandView::OrderGet(view))
- }
- })
+ let mut view = crate::runtime::order::get(config, args)?;
+ rewrite_order_actions(&mut view.actions);
+ Ok(command_output(
+ view.disposition(),
+ CommandView::OrderGet(view),
+ ))
}
pub fn list(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> {
- let view = crate::runtime::order::list(config)?;
- Ok(match view.disposition() {
- CommandDisposition::Success => CommandOutput::success(CommandView::OrderList(view)),
- CommandDisposition::Unconfigured => {
- CommandOutput::unconfigured(CommandView::OrderList(view))
- }
- CommandDisposition::ExternalUnavailable => {
- CommandOutput::external_unavailable(CommandView::OrderList(view))
- }
- CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::OrderList(view)),
- CommandDisposition::InternalError => {
- CommandOutput::internal_error(CommandView::OrderList(view))
- }
- })
+ let mut view = crate::runtime::order::list(config)?;
+ rewrite_order_actions(&mut view.actions);
+ Ok(command_output(
+ view.disposition(),
+ CommandView::OrderList(view),
+ ))
}
pub fn submit(
config: &RuntimeConfig,
args: &OrderSubmitArgs,
) -> Result<CommandOutput, RuntimeError> {
- let view = crate::runtime::order::submit(config, args)?;
- Ok(match view.disposition() {
- CommandDisposition::Success => CommandOutput::success(CommandView::OrderSubmit(view)),
- CommandDisposition::Unconfigured => {
- CommandOutput::unconfigured(CommandView::OrderSubmit(view))
- }
- CommandDisposition::ExternalUnavailable => {
- CommandOutput::external_unavailable(CommandView::OrderSubmit(view))
- }
- CommandDisposition::Unsupported => {
- CommandOutput::unsupported(CommandView::OrderSubmit(view))
- }
- CommandDisposition::InternalError => {
- CommandOutput::internal_error(CommandView::OrderSubmit(view))
- }
- })
+ let mut view = crate::runtime::order::submit(config, args)?;
+ rewrite_order_actions(&mut view.actions);
+
+ if args.watch
+ && config.output.format == OutputFormat::Human
+ && should_watch_submitted_order(&view)
+ {
+ let watch_config = watch_runtime_config(config);
+ let mut watch = crate::runtime::order::watch(
+ &watch_config,
+ &OrderWatchArgs {
+ key: view.order_id.clone(),
+ frames: None,
+ interval_ms: 1_000,
+ },
+ )?;
+ rewrite_order_actions(&mut watch.actions);
+ let combined = OrderSubmitWatchView {
+ submit: view,
+ watch,
+ };
+ return Ok(command_output(
+ combined.disposition(),
+ CommandView::OrderSubmitWatch(combined),
+ ));
+ }
+
+ Ok(command_output(
+ view.disposition(),
+ CommandView::OrderSubmit(view),
+ ))
}
pub fn watch(config: &RuntimeConfig, args: &OrderWatchArgs) -> Result<CommandOutput, RuntimeError> {
- let view = crate::runtime::order::watch(config, args)?;
- Ok(match view.disposition() {
- CommandDisposition::Success => CommandOutput::success(CommandView::OrderWatch(view)),
- CommandDisposition::Unconfigured => {
- CommandOutput::unconfigured(CommandView::OrderWatch(view))
- }
- CommandDisposition::ExternalUnavailable => {
- CommandOutput::external_unavailable(CommandView::OrderWatch(view))
- }
- CommandDisposition::Unsupported => {
- CommandOutput::unsupported(CommandView::OrderWatch(view))
- }
- CommandDisposition::InternalError => {
- CommandOutput::internal_error(CommandView::OrderWatch(view))
- }
- })
+ let mut view = crate::runtime::order::watch(config, args)?;
+ rewrite_order_actions(&mut view.actions);
+ Ok(command_output(
+ view.disposition(),
+ CommandView::OrderWatch(view),
+ ))
}
pub fn cancel(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput, RuntimeError> {
- let view = crate::runtime::order::cancel(config, args)?;
- Ok(match view.disposition() {
- CommandDisposition::Success => CommandOutput::success(CommandView::OrderCancel(view)),
- CommandDisposition::Unconfigured => {
- CommandOutput::unconfigured(CommandView::OrderCancel(view))
- }
- CommandDisposition::ExternalUnavailable => {
- CommandOutput::external_unavailable(CommandView::OrderCancel(view))
- }
- CommandDisposition::Unsupported => {
- CommandOutput::unsupported(CommandView::OrderCancel(view))
- }
- CommandDisposition::InternalError => {
- CommandOutput::internal_error(CommandView::OrderCancel(view))
- }
- })
+ let mut view = crate::runtime::order::cancel(config, args)?;
+ rewrite_order_actions(&mut view.actions);
+ Ok(command_output(
+ view.disposition(),
+ CommandView::OrderCancel(view),
+ ))
}
pub fn history(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> {
- let view = crate::runtime::order::history(config)?;
- Ok(match view.disposition() {
- CommandDisposition::Success => CommandOutput::success(CommandView::OrderHistory(view)),
- CommandDisposition::Unconfigured => {
- CommandOutput::unconfigured(CommandView::OrderHistory(view))
- }
- CommandDisposition::ExternalUnavailable => {
- CommandOutput::external_unavailable(CommandView::OrderHistory(view))
- }
- CommandDisposition::Unsupported => {
- CommandOutput::unsupported(CommandView::OrderHistory(view))
- }
- CommandDisposition::InternalError => {
- CommandOutput::internal_error(CommandView::OrderHistory(view))
- }
- })
+ let mut view = crate::runtime::order::history(config)?;
+ rewrite_order_actions(&mut view.actions);
+ Ok(command_output(
+ view.disposition(),
+ CommandView::OrderHistory(view),
+ ))
+}
+
+fn should_watch_submitted_order(view: &OrderSubmitView) -> bool {
+ !matches!(
+ view.state.as_str(),
+ "dry_run" | "error" | "missing" | "unavailable" | "unconfigured"
+ ) && view
+ .job
+ .as_ref()
+ .is_some_and(|job| job.job_id.as_str() != "not_submitted")
+}
+
+fn watch_runtime_config(config: &RuntimeConfig) -> RuntimeConfig {
+ let mut watch_config = config.clone();
+ if let Some(binding) = config.capability_binding(WRITE_PLANE_TRADE_JSONRPC_CAPABILITY) {
+ if binding.target_kind == CapabilityBindingTargetKind::ExplicitEndpoint {
+ watch_config.rpc.url = binding.target.clone();
+ }
+ }
+ watch_config
+}
+
+fn rewrite_order_actions(actions: &mut Vec<String>) {
+ for action in actions {
+ *action = rewrite_order_action(action.as_str());
+ }
+}
+
+fn rewrite_order_action(action: &str) -> String {
+ if action == "radroots order new" {
+ return "radroots order create".to_owned();
+ }
+ if action == "radroots order ls" {
+ return "radroots order list".to_owned();
+ }
+ if let Some(key) = action.strip_prefix("radroots order get ") {
+ return format!("radroots order view {key}");
+ }
+ action.to_owned()
+}
+
+fn command_output(disposition: CommandDisposition, view: CommandView) -> CommandOutput {
+ match disposition {
+ CommandDisposition::Success => CommandOutput::success(view),
+ CommandDisposition::Unconfigured => CommandOutput::unconfigured(view),
+ CommandDisposition::ExternalUnavailable => CommandOutput::external_unavailable(view),
+ CommandDisposition::Unsupported => CommandOutput::unsupported(view),
+ CommandDisposition::InternalError => CommandOutput::internal_error(view),
+ }
}
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -114,6 +114,7 @@ pub enum CommandView {
OrderList(OrderListView),
OrderNew(OrderNewView),
OrderSubmit(OrderSubmitView),
+ OrderSubmitWatch(OrderSubmitWatchView),
OrderWatch(OrderWatchView),
RpcSessions(RpcSessionsView),
RpcStatus(RpcStatusView),
@@ -1236,6 +1237,22 @@ impl OrderSubmitView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct OrderSubmitWatchView {
+ pub submit: OrderSubmitView,
+ pub watch: OrderWatchView,
+}
+
+impl OrderSubmitWatchView {
+ pub fn disposition(&self) -> CommandDisposition {
+ let submit = self.submit.disposition();
+ if submit != CommandDisposition::Success {
+ return submit;
+ }
+ self.watch.disposition()
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct OrderWatchView {
pub state: String,
pub source: String,
diff --git a/src/main.rs b/src/main.rs
@@ -12,7 +12,7 @@ use std::process::ExitCode;
use crate::cli::CliArgs;
use crate::commands::dispatch;
use crate::render::render_output;
-use crate::runtime::config::RuntimeConfig;
+use crate::runtime::config::{OutputFormat, RuntimeConfig};
use crate::runtime::logging::initialize_logging;
fn main() -> ExitCode {
@@ -39,6 +39,16 @@ fn validate_command_contracts(
command: &crate::cli::Command,
config: &RuntimeConfig,
) -> Result<(), runtime::RuntimeError> {
+ if let crate::cli::Command::Order(order) = command {
+ if let crate::cli::OrderCommand::Submit(args) = &order.command {
+ if args.watch && config.output.format != OutputFormat::Human {
+ return Err(runtime::RuntimeError::Config(
+ "`order submit --watch` only supports human output; use `order submit` and `order watch` for machine output".to_owned(),
+ ));
+ }
+ }
+ }
+
if !command.supports_output_format(config.output.format) {
return Err(runtime::RuntimeError::Config(format!(
"`{}` does not support --{}",
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -7,10 +7,10 @@ use crate::domain::runtime::{
ListingMutationView, ListingNewView, ListingValidateView, LocalBackupView, LocalExportView,
LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView,
OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView,
- OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView,
- RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SellAddView,
- SellCheckView, SellDraftMutationView, SellMutationView, SellShowView, SetupView, StatusView,
- SyncActionView, SyncStatusView, SyncWatchView,
+ OrderSubmitWatchView, OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView,
+ RpcStatusView, RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView,
+ SellAddView, SellCheckView, SellDraftMutationView, SellMutationView, SellShowView, SetupView,
+ StatusView, SyncActionView, SyncStatusView, SyncWatchView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::{OutputConfig, OutputFormat};
@@ -101,6 +101,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::OrderSubmit(view) => {
render_order_submit(stdout, view)?;
}
+ CommandView::OrderSubmitWatch(view) => {
+ render_order_submit_watch(stdout, view)?;
+ }
CommandView::OrderWatch(view) => {
render_order_watch(stdout, view)?;
}
@@ -317,6 +320,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::OrderSubmitWatch(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::OrderWatch(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -1668,6 +1675,75 @@ fn render_order_submit(stdout: &mut dyn Write, view: &OrderSubmitView) -> Result
Ok(())
}
+fn render_order_submit_watch(
+ stdout: &mut dyn Write,
+ view: &OrderSubmitWatchView,
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "{}", order_submit_watch_headline(&view.submit))?;
+ writeln!(stdout)?;
+
+ writeln!(stdout, "Order")?;
+ let mut order_rows = vec![
+ ("ID", view.submit.order_id.clone()),
+ ("State", humanize_machine_label(view.submit.state.as_str())),
+ ];
+ push_row(
+ &mut order_rows,
+ "Listing",
+ first_present([
+ view.submit.listing_lookup.as_deref(),
+ view.submit.listing_addr.as_deref(),
+ ]),
+ );
+ push_row(
+ &mut order_rows,
+ "Buyer",
+ first_present([
+ view.submit.buyer_account_id.as_deref(),
+ view.submit.buyer_pubkey.as_deref(),
+ ]),
+ );
+ render_field_rows(stdout, order_rows.as_slice())?;
+
+ if let Some(job) = &view.submit.job {
+ writeln!(stdout, "Job")?;
+ let mut job_rows = vec![
+ ("Job", job.job_id.clone()),
+ ("State", humanize_machine_label(job.state.as_str())),
+ ];
+ push_row(&mut job_rows, "Event", job.event_id.clone());
+ render_field_rows(stdout, job_rows.as_slice())?;
+ }
+
+ writeln!(stdout, "Watching order {}", view.watch.order_id)?;
+
+ if view.watch.frames.is_empty() {
+ if let Some(reason) = &view.watch.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ } else {
+ for frame in &view.watch.frames {
+ writeln!(stdout)?;
+ writeln!(
+ stdout,
+ "{}",
+ crate::runtime::job::format_clock(frame.observed_at_unix)
+ )?;
+ let rows = vec![
+ ("State", humanize_machine_label(frame.state.as_str())),
+ ("Summary", frame.summary.clone()),
+ ];
+ render_field_rows(stdout, rows.as_slice())?;
+ }
+ }
+
+ if !view.watch.actions.is_empty() {
+ render_item_section(stdout, "Next", &view.watch.actions)?;
+ }
+ Ok(())
+}
+
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),
@@ -3206,6 +3282,28 @@ fn non_empty_str(value: &str) -> Option<&str> {
}
}
+fn humanize_machine_label(value: &str) -> String {
+ value
+ .split('_')
+ .filter(|segment| !segment.is_empty())
+ .map(capitalize_ascii_word)
+ .collect::<Vec<_>>()
+ .join(" ")
+}
+
+fn order_submit_watch_headline(view: &OrderSubmitView) -> &'static str {
+ match view.state.as_str() {
+ "already_submitted" => "Order already submitted",
+ "deduplicated" => "Order already in progress",
+ "dry_run" => "Dry run only",
+ "error" => "Order submit failed",
+ "missing" => "Order draft not found",
+ "unavailable" => "Order submit unavailable",
+ "unconfigured" => "Not ready yet",
+ _ => "Order submitted",
+ }
+}
+
fn humanize_delivery_method(value: &str) -> String {
value
.split('_')
@@ -3620,11 +3718,12 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::MycStatus(_) => "myc status",
CommandView::NetStatus(_) => "net status",
CommandView::OrderCancel(_) => "order cancel",
- CommandView::OrderGet(_) => "order get",
+ CommandView::OrderGet(_) => "order view",
CommandView::OrderHistory(_) => "order history",
- CommandView::OrderList(_) => "order ls",
- CommandView::OrderNew(_) => "order new",
+ CommandView::OrderList(_) => "order list",
+ CommandView::OrderNew(_) => "order create",
CommandView::OrderSubmit(_) => "order submit",
+ CommandView::OrderSubmitWatch(_) => "order submit --watch",
CommandView::OrderWatch(_) => "order watch",
CommandView::RpcSessions(_) => "rpc sessions",
CommandView::RpcStatus(_) => "rpc status",
diff --git a/tests/help.rs b/tests/help.rs
@@ -122,5 +122,6 @@ fn order_help_prefers_create_view_and_list() {
assert!(stdout.contains("watch"));
assert!(stdout.contains("cancel"));
assert!(stdout.contains("history"));
+ assert!(stdout.contains("radroots order submit ord_demo --watch"));
assert!(stdout.contains("Compatibility aliases: new, get, ls."));
}
diff --git a/tests/order.rs b/tests/order.rs
@@ -484,6 +484,79 @@ fn order_get_and_ls_read_local_drafts_and_report_missing() {
}
#[test]
+fn order_create_view_and_list_aliases_wrap_the_existing_draft_surfaces() {
+ let _guard = order_test_guard();
+ let dir = tempdir().expect("tempdir");
+
+ let account_output = order_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+
+ let create_output = order_command_in(dir.path())
+ .args([
+ "--json",
+ "order",
+ "create",
+ "--listing",
+ "pasture-eggs",
+ "--listing-addr",
+ "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg",
+ "--bin",
+ "bin-1",
+ "--qty",
+ "2",
+ ])
+ .output()
+ .expect("run order create");
+ assert!(create_output.status.success());
+ let create_json: Value =
+ serde_json::from_slice(create_output.stdout.as_slice()).expect("create json");
+ let order_id = create_json["order_id"].as_str().expect("order id");
+ assert_eq!(create_json["state"], "draft_created");
+ assert_eq!(
+ create_json["actions"][0],
+ format!("radroots order view {order_id}")
+ );
+
+ let view_output = order_command_in(dir.path())
+ .args(["--json", "order", "view", order_id])
+ .output()
+ .expect("run order view");
+ assert!(view_output.status.success());
+ let view_json: Value =
+ serde_json::from_slice(view_output.stdout.as_slice()).expect("view json");
+ assert_eq!(view_json["state"], "ready");
+ assert_eq!(view_json["order_id"], order_id);
+
+ let list_output = order_command_in(dir.path())
+ .args(["--json", "order", "list"])
+ .output()
+ .expect("run order list");
+ assert!(list_output.status.success());
+ let list_json: Value =
+ serde_json::from_slice(list_output.stdout.as_slice()).expect("list json");
+ assert_eq!(list_json["count"], 1);
+ assert_eq!(list_json["orders"][0]["id"], order_id);
+}
+
+#[test]
+fn order_list_empty_prefers_the_create_follow_up() {
+ let _guard = order_test_guard();
+ let dir = tempdir().expect("tempdir");
+
+ let output = order_command_in(dir.path())
+ .args(["--json", "order", "list"])
+ .output()
+ .expect("run empty order list");
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("list json");
+ assert_eq!(json["state"], "empty");
+ assert_eq!(json["actions"][0], "radroots order create");
+}
+
+#[test]
fn order_get_surfaces_recorded_job_metadata_from_the_local_draft_store() {
let _guard = order_test_guard();
let dir = tempdir().expect("tempdir");
@@ -653,6 +726,113 @@ fn order_submit_persists_submission_metadata_and_reports_job() {
}
#[test]
+fn order_submit_watch_rejects_json_output() {
+ let _guard = order_test_guard();
+ let dir = tempdir().expect("tempdir");
+
+ let output = order_command_in(dir.path())
+ .args(["--json", "order", "submit", "ord_demo", "--watch"])
+ .output()
+ .expect("run order submit watch json");
+ assert_eq!(output.status.code(), Some(2));
+ let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
+ assert!(stderr.contains("`order submit --watch` only supports human output"));
+}
+
+#[test]
+fn order_submit_watch_appends_human_watch_snapshots() {
+ let _guard = order_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let account_output = order_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+ let account_json: Value =
+ serde_json::from_slice(account_output.stdout.as_slice()).expect("account json");
+ let buyer_pubkey = account_json["public_identity"]["public_key_hex"]
+ .as_str()
+ .expect("buyer pubkey")
+ .to_owned();
+
+ let create_output = order_command_in(dir.path())
+ .args([
+ "--json",
+ "order",
+ "create",
+ "--listing",
+ "pasture-eggs",
+ "--listing-addr",
+ "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg",
+ "--bin",
+ "bin-1",
+ ])
+ .output()
+ .expect("run order create");
+ assert!(create_output.status.success());
+ let create_json: Value =
+ serde_json::from_slice(create_output.stdout.as_slice()).expect("create json");
+ let order_id = create_json["order_id"].as_str().expect("order id");
+
+ let server = MockRpcServer::start(move |body, _auth_header| {
+ match body["method"].as_str().unwrap_or_default() {
+ "nip46.session.list" => MockRpcResponse::success(json!([sample_session(
+ "sess_order_watch_01",
+ buyer_pubkey.as_str(),
+ &["sign_event"],
+ true
+ )])),
+ "bridge.order.request" => MockRpcResponse::success(serde_json::json!({
+ "deduplicated": false,
+ "job": sample_bridge_job(
+ "job_order_watch_01",
+ "accepted",
+ false,
+ "sess_order_watch_01"
+ ),
+ })),
+ "bridge.job.status" => MockRpcResponse::success(sample_bridge_job(
+ "job_order_watch_01",
+ "completed",
+ true,
+ "sess_order_watch_01",
+ )),
+ other => panic!("unexpected mock rpc method {other}"),
+ }
+ });
+ write_workspace_config(
+ dir.path(),
+ workspace_config_with_write_plane("", server.url().as_str()).as_str(),
+ );
+
+ let output = order_command_in(dir.path())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "watch-token")
+ .args([
+ "order",
+ "submit",
+ order_id,
+ "--watch",
+ "--signer-session-id",
+ "sess_order_watch_01",
+ ])
+ .output()
+ .expect("run order submit watch");
+ let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
+ let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
+ assert!(
+ output.status.success(),
+ "status: {:?}\nstdout:\n{stdout}\nstderr:\n{stderr}",
+ output.status.code()
+ );
+ assert!(stdout.contains("Order submitted"));
+ assert!(stdout.contains("Watching order"));
+ assert!(stdout.contains(order_id));
+ assert!(stdout.contains("Completed"));
+ assert!(stdout.contains("submitted to 2 relays"));
+ assert!(!stdout.contains("order ·"));
+}
+
+#[test]
fn order_watch_reports_job_frames_for_submitted_order() {
let _guard = order_test_guard();
let dir = tempdir().expect("tempdir");