cli

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

commit 2f3c47373b2927a5f3ccf7e2effa26fe78869f45
parent 28818d7077dbf920fe410e6030b458d474832b30
Author: triesap <tyson@radroots.org>
Date:   Fri,  8 May 2026 15:30:35 +0000

order: defer event watch truthfully

- return structured not_implemented for order event watch
- keep deferred watch out of local draft lookup and relay runtime paths
- share next action extraction between success and failure envelopes
- cover the deferred watch envelope in adapter tests

Diffstat:
Msrc/operation_adapter.rs | 74+++++++++++++-------------------------------------------------------------
Msrc/operation_order.rs | 71+++++++++++++++++++++++++++++++++++++----------------------------------
Msrc/output_contract.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 127 insertions(+), 96 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -10,7 +10,7 @@ use crate::domain::runtime::CommandDisposition; use crate::operation_registry::{OPERATION_REGISTRY, OperationSpec, get_operation}; use crate::output_contract::{ CliExitCode, EnvelopeActor, EnvelopeContext, NextAction, OUTPUT_SCHEMA_VERSION, OutputEnvelope, - OutputError, OutputWarning, + OutputError, OutputWarning, next_actions_from_result_value, }; use crate::runtime::RuntimeError; use crate::runtime::accounts::AccountRuntimeFailure; @@ -260,66 +260,7 @@ impl<P: OperationResultPayload> OperationResult<P> { } fn next_actions_from_result(result: &Value) -> Vec<NextAction> { - result - .get("actions") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .filter_map(next_action_from_action_string) - .fold(Vec::<NextAction>::new(), |mut actions, action| { - if !actions - .iter() - .any(|existing| existing.command == action.command) - { - actions.push(action); - } - actions - }) -} - -fn next_action_from_action_string(action: &str) -> Option<NextAction> { - let command = action.trim().strip_prefix("run ").unwrap_or(action).trim(); - if !command.starts_with("radroots ") { - return None; - } - Some(NextAction { - label: next_action_label(command), - command: command.to_owned(), - }) -} - -fn next_action_label(command: &str) -> String { - let parts = command.split_whitespace().collect::<Vec<_>>(); - let mut index = usize::from(parts.first().is_some_and(|part| *part == "radroots")); - let mut labels = Vec::new(); - while index < parts.len() { - let part = parts[index]; - if part.starts_with("--") { - index += 1; - if matches!( - part, - "--format" - | "--account-id" - | "--relay" - | "--publish-mode" - | "--idempotency-key" - | "--correlation-id" - | "--approval-token" - ) && index < parts.len() - { - index += 1; - } - continue; - } - labels.push(part); - index += 1; - } - if labels.is_empty() { - "radroots".to_owned() - } else { - labels.join(" ") - } + next_actions_from_result_value(result) } pub trait OperationService<P: OperationRequestPayload> { @@ -531,6 +472,17 @@ impl OperationAdapterError { } } + pub fn not_implemented_with_detail(operation_id: &str, message: String, detail: Value) -> Self { + Self::DetailedFailure { + operation_id: operation_id.to_owned(), + code: "not_implemented".to_owned(), + class: "operation".to_owned(), + message, + exit_code: CliExitCode::RuntimeUnavailable, + detail_json: detail.to_string(), + } + } + pub fn network_unavailable_with_detail( operation_id: &str, message: String, diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -25,9 +25,11 @@ use crate::runtime::config::RuntimeConfig; use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs, - OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, + OrderSubmitArgs, RecordLookupArgs, }; +const ORDER_EVENT_WATCH_DEFERRED_REASON: &str = "relay-backed order event watch is not implemented"; + pub struct OrderOperationService<'a> { config: &'a RuntimeConfig, } @@ -511,13 +513,18 @@ impl OperationService<OrderEventWatchRequest> for OrderOperationService<'_> { &self, request: OperationRequest<OrderEventWatchRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = OrderWatchArgs { - key: required_order_key(&request)?, - frames: usize_input(&request, "frames").or(Some(1)), - interval_ms: u64_input(&request, "interval_ms").unwrap_or(1_000), - }; - let view = map_runtime(crate::runtime::order::watch(self.config, &args))?; - serialized_target_result::<OrderEventWatchResult, _>(&view) + let order_id = required_order_key(&request)?; + let action = format!("radroots order status get {order_id}"); + Err(OperationAdapterError::not_implemented_with_detail( + request.operation_id(), + ORDER_EVENT_WATCH_DEFERRED_REASON.to_owned(), + json!({ + "state": "not_implemented", + "order_id": order_id, + "reason": ORDER_EVENT_WATCH_DEFERRED_REASON, + "actions": [action], + }), + )) } } @@ -1232,18 +1239,6 @@ where request.payload.input().get(key).and_then(Value::as_bool) } -fn usize_input<P>(request: &OperationRequest<P>, key: &str) -> Option<usize> -where - P: OperationRequestPayload + OperationRequestData, -{ - request - .payload - .input() - .get(key) - .and_then(Value::as_u64) - .and_then(|value| usize::try_from(value).ok()) -} - fn u32_input<P>(request: &OperationRequest<P>, key: &str) -> Option<u32> where P: OperationRequestPayload + OperationRequestData, @@ -1256,13 +1251,6 @@ where .and_then(|value| u32::try_from(value).ok()) } -fn u64_input<P>(request: &OperationRequest<P>, key: &str) -> Option<u64> -where - P: OperationRequestPayload + OperationRequestData, -{ - request.payload.input().get(key).and_then(Value::as_u64) -} - fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) } @@ -1713,7 +1701,7 @@ mod tests { } #[test] - fn order_event_watch_reports_missing_order_with_target_actions() { + fn order_event_watch_returns_deferred_error_with_target_action() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path()); let service = OperationAdapter::new(OrderOperationService::new(&config)); @@ -1722,15 +1710,30 @@ mod tests { OrderEventWatchRequest::from_data(data(&[("order_id", "ord_missing")])), ) .expect("order event watch request"); - let envelope = service + let error = service .execute(request) - .expect("order event watch result") - .to_envelope(OperationContext::default().envelope_context("req_order_watch")) - .expect("order event watch envelope"); + .expect_err("order event watch deferred"); + let envelope = crate::output_contract::OutputEnvelope::failure( + "order.event.watch", + error.to_output_error(), + OperationContext::default().envelope_context("req_order_watch"), + ); assert_eq!(envelope.operation_id, "order.event.watch"); - assert_eq!(envelope.result["state"], "missing"); - assert_eq!(envelope.result["actions"][0], "radroots order list"); + assert!(envelope.result.is_null()); + assert_eq!(envelope.errors[0].code, "not_implemented"); + assert_eq!( + envelope.errors[0].detail.as_ref().unwrap()["state"], + "not_implemented" + ); + assert_eq!( + envelope.errors[0].detail.as_ref().unwrap()["order_id"], + "ord_missing" + ); + assert_eq!( + envelope.next_actions[0].command, + "radroots order status get ord_missing" + ); } fn sample_config(root: &Path) -> RuntimeConfig { diff --git a/src/output_contract.rs b/src/output_contract.rs @@ -77,6 +77,7 @@ impl OutputEnvelope { context: EnvelopeContext, ) -> Self { let operation_id = operation_id.into(); + let next_actions = next_actions_from_error_detail(&error); Self { schema_version: OUTPUT_SCHEMA_VERSION, kind: operation_id.clone(), @@ -89,7 +90,7 @@ impl OutputEnvelope { result: Value::Null, warnings: Vec::new(), errors: vec![error], - next_actions: Vec::new(), + next_actions, } } @@ -131,6 +132,81 @@ impl OutputEnvelope { } } +pub fn next_actions_from_result_value(result: &Value) -> Vec<NextAction> { + next_actions_from_actions_value(result.get("actions")) +} + +fn next_actions_from_error_detail(error: &OutputError) -> Vec<NextAction> { + next_actions_from_actions_value( + error + .detail + .as_ref() + .and_then(|detail| detail.get("actions")), + ) +} + +fn next_actions_from_actions_value(actions_value: Option<&Value>) -> Vec<NextAction> { + actions_value + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter_map(next_action_from_action_string) + .fold(Vec::<NextAction>::new(), |mut actions, action| { + if !actions + .iter() + .any(|existing| existing.command == action.command) + { + actions.push(action); + } + actions + }) +} + +fn next_action_from_action_string(action: &str) -> Option<NextAction> { + let command = action.trim().strip_prefix("run ").unwrap_or(action).trim(); + if !command.starts_with("radroots ") { + return None; + } + Some(NextAction { + label: next_action_label(command), + command: command.to_owned(), + }) +} + +fn next_action_label(command: &str) -> String { + let parts = command.split_whitespace().collect::<Vec<_>>(); + let mut index = usize::from(parts.first().is_some_and(|part| *part == "radroots")); + let mut labels = Vec::new(); + while index < parts.len() { + let part = parts[index]; + if part.starts_with("--") { + index += 1; + if matches!( + part, + "--format" + | "--account-id" + | "--relay" + | "--publish-mode" + | "--idempotency-key" + | "--correlation-id" + | "--approval-token" + ) && index < parts.len() + { + index += 1; + } + continue; + } + labels.push(part); + index += 1; + } + if labels.is_empty() { + "radroots".to_owned() + } else { + labels.join(" ") + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct OutputWarning { pub code: String,