cli

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

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

order: preserve failure detail actions

- add detailed not_found support for order failures
- carry status submit history and decision state into error detail
- derive failure next actions from structured error actions
- cover failure action and unconfigured decision envelopes

Diffstat:
Msrc/operation_adapter.rs | 11+++++++++++
Msrc/operation_order.rs | 225++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/output_contract.rs | 26++++++++++++++++++++++++++
3 files changed, 227 insertions(+), 35 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -465,6 +465,17 @@ impl OperationAdapterError { } } + pub fn not_found_with_detail(operation_id: &str, message: String, detail: Value) -> Self { + Self::DetailedFailure { + operation_id: operation_id.to_owned(), + code: "not_found".to_owned(), + class: "resource".to_owned(), + message, + exit_code: CliExitCode::NotFound, + detail_json: detail.to_string(), + } + } + pub fn not_implemented(operation_id: &str, message: String) -> Self { Self::NotImplemented { operation_id: operation_id.to_owned(), diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -570,6 +570,18 @@ where detail, )) } + } else if disposition == CommandDisposition::Unconfigured { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + order_decision_error_detail(view), + )) + } else if disposition == CommandDisposition::NotFound { + Err(OperationAdapterError::not_found_with_detail( + operation_id, + message, + order_decision_error_detail(view), + )) } else { Err(OperationAdapterError::from_command_disposition( operation_id, @@ -1079,15 +1091,66 @@ where .reason .clone() .unwrap_or_else(|| format!("order status finished with state `{}`", view.state)); - Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - message, - )) + if disposition == CommandDisposition::ExternalUnavailable { + let detail = order_status_error_detail(view); + if !view.failed_relays.is_empty() && view.connected_relays.is_empty() { + Err(OperationAdapterError::network_unavailable_with_detail( + operation_id, + message, + detail, + )) + } else { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + detail, + )) + } + } else if disposition == CommandDisposition::Unconfigured { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + order_status_error_detail(view), + )) + } else { + Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + message, + )) + } } } } +fn order_status_error_detail(view: &OrderStatusView) -> Value { + json!({ + "state": &view.state, + "order_id": &view.order_id, + "request_event_id": &view.request_event_id, + "decision_event_id": &view.decision_event_id, + "agreement_event_id": &view.agreement_event_id, + "listing_event_id": &view.listing_event_id, + "listing_addr": &view.listing_addr, + "buyer_pubkey": &view.buyer_pubkey, + "seller_pubkey": &view.seller_pubkey, + "last_event_id": &view.last_event_id, + "revision": &view.revision, + "inventory": &view.inventory, + "fulfillment": &view.fulfillment, + "lifecycle": &view.lifecycle, + "payment": &view.payment, + "reducer_issues": &view.reducer_issues, + "target_relays": &view.target_relays, + "connected_relays": &view.connected_relays, + "failed_relays": &view.failed_relays, + "fetched_count": view.fetched_count, + "decoded_count": view.decoded_count, + "skipped_count": view.skipped_count, + "actions": &view.actions, + }) +} + fn serialized_target_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> where R: OperationResultData, @@ -1110,45 +1173,81 @@ where .reason .clone() .unwrap_or_else(|| format!("order submit finished with state `{}`", view.state)); - if !view.issues.is_empty() - && matches!( - disposition, - CommandDisposition::Unconfigured | CommandDisposition::ValidationFailed - ) - { - let detail = json!({ - "state": &view.state, - "order_id": &view.order_id, - "file": &view.file, - "listing_addr": &view.listing_addr, - "listing_event_id": &view.listing_event_id, - "issues": &view.issues, - "actions": &view.actions, - }); - if disposition == CommandDisposition::ValidationFailed { + let detail = order_submit_error_detail(view); + match disposition { + CommandDisposition::NotFound => Err(OperationAdapterError::not_found_with_detail( + operation_id, + message, + detail, + )), + CommandDisposition::ValidationFailed => { Err(OperationAdapterError::validation_failed_with_detail( operation_id, message, detail, )) - } else { + } + CommandDisposition::Unconfigured => { Err(OperationAdapterError::operation_unavailable_with_detail( operation_id, message, detail, )) } - } else { - Err(OperationAdapterError::from_command_disposition( + CommandDisposition::ExternalUnavailable => { + if !view.failed_relays.is_empty() && view.connected_relays.is_empty() { + Err(OperationAdapterError::network_unavailable_with_detail( + operation_id, + message, + detail, + )) + } else { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + detail, + )) + } + } + _ => Err(OperationAdapterError::from_command_disposition( operation_id, disposition, message, - )) + )), } } } } +fn order_submit_error_detail(view: &OrderSubmitView) -> Value { + json!({ + "state": &view.state, + "source": &view.source, + "order_id": &view.order_id, + "file": &view.file, + "listing_lookup": &view.listing_lookup, + "listing_addr": &view.listing_addr, + "listing_event_id": &view.listing_event_id, + "buyer_account_id": &view.buyer_account_id, + "buyer_pubkey": &view.buyer_pubkey, + "seller_pubkey": &view.seller_pubkey, + "event_id": &view.event_id, + "event_kind": view.event_kind, + "dry_run": view.dry_run, + "deduplicated": view.deduplicated, + "target_relays": &view.target_relays, + "connected_relays": &view.connected_relays, + "acknowledged_relays": &view.acknowledged_relays, + "failed_relays": &view.failed_relays, + "idempotency_key": &view.idempotency_key, + "signer_mode": &view.signer_mode, + "signer_session_id": &view.signer_session_id, + "requested_signer_session_id": &view.requested_signer_session_id, + "issues": &view.issues, + "actions": &view.actions, + }) +} + fn history_result<R>( operation_id: &str, view: &crate::domain::runtime::OrderHistoryView, @@ -1163,19 +1262,17 @@ where format!("order event list finished with state `{}`", view.state) }); if disposition == CommandDisposition::ExternalUnavailable { + let detail = order_history_error_detail(view); Err(OperationAdapterError::network_unavailable_with_detail( operation_id, message, - json!({ - "state": &view.state, - "seller_pubkey": &view.seller_pubkey, - "target_relays": &view.target_relays, - "connected_relays": &view.connected_relays, - "failed_relays": &view.failed_relays, - "fetched_count": view.fetched_count, - "decoded_count": view.decoded_count, - "skipped_count": view.skipped_count, - }), + detail, + )) + } else if disposition == CommandDisposition::Unconfigured { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + order_history_error_detail(view), )) } else { Err(OperationAdapterError::from_command_disposition( @@ -1188,6 +1285,21 @@ where } } +fn order_history_error_detail(view: &crate::domain::runtime::OrderHistoryView) -> Value { + json!({ + "state": &view.state, + "seller_pubkey": &view.seller_pubkey, + "target_relays": &view.target_relays, + "connected_relays": &view.connected_relays, + "failed_relays": &view.failed_relays, + "fetched_count": view.fetched_count, + "decoded_count": view.decoded_count, + "skipped_count": view.skipped_count, + "count": view.count, + "actions": &view.actions, + }) +} + fn required_order_key<P>(request: &OperationRequest<P>) -> Result<String, OperationAdapterError> where P: OperationRequestPayload + OperationRequestData, @@ -1360,6 +1472,18 @@ mod tests { assert_eq!(output_error.code, "not_found"); assert_eq!(output_error.exit_code, 4); assert!(output_error.message.contains("ord_missing")); + let envelope = crate::output_contract::OutputEnvelope::failure( + "order.submit", + output_error, + context.envelope_context("req_order_submit"), + ); + let detail = envelope.errors[0].detail.as_ref().expect("submit detail"); + assert_eq!(detail["state"], "missing"); + assert_eq!(detail["order_id"], "ord_missing"); + assert_eq!(detail["actions"][0], "radroots order list"); + assert_eq!(detail["actions"][1], "radroots basket create"); + assert_eq!(envelope.next_actions[0].command, "radroots order list"); + assert_eq!(envelope.next_actions[1].command, "radroots basket create"); } #[test] @@ -1378,6 +1502,31 @@ mod tests { } #[test] + fn order_accept_unconfigured_preserves_decision_detail() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let mut context = OperationContext::default(); + context.dry_run = true; + let accept = OperationRequest::new( + context, + OrderAcceptRequest::from_data(data(&[("order_id", "ord_pending")])), + ) + .expect("order accept request"); + let error = service + .execute(accept) + .expect_err("order accept unconfigured"); + let output_error = error.to_output_error(); + let detail = output_error.detail.as_ref().expect("decision detail"); + + assert_eq!(output_error.code, "operation_unavailable"); + assert_eq!(detail["state"], "unconfigured"); + assert_eq!(detail["order_id"], "ord_pending"); + assert_eq!(detail["decision"], "accepted"); + assert!(detail["target_relays"].as_array().unwrap().is_empty()); + } + + #[test] fn order_decision_already_decided_maps_to_validation_failure() { let view = already_decided_view(); let error = match decision_result::<OrderAcceptResult>("order.accept", &view) { @@ -1678,6 +1827,12 @@ mod tests { assert_eq!(output_error.code, "operation_unavailable"); assert!(output_error.message.contains("configured relay")); + let detail = output_error.detail.as_ref().expect("status detail"); + assert_eq!(detail["state"], "unconfigured"); + assert_eq!(detail["order_id"], "ord_pending"); + assert_eq!(detail["fetched_count"], 0); + assert_eq!(detail["decoded_count"], 0); + assert_eq!(detail["skipped_count"], 0); } #[test] diff --git a/src/output_contract.rs b/src/output_contract.rs @@ -381,6 +381,32 @@ mod tests { } #[test] + fn failure_envelope_derives_next_actions_from_error_detail() { + let mut error = OutputError::new( + "not_found", + "order draft was not found", + CliExitCode::NotFound, + ); + error.detail = Some(json!({ + "actions": [ + "radroots order list", + "run radroots basket create" + ] + })); + let envelope = OutputEnvelope::failure( + "order.submit", + error, + EnvelopeContext::new("req_order", true), + ); + + assert_eq!(envelope.next_actions.len(), 2); + assert_eq!(envelope.next_actions[0].label, "order list"); + assert_eq!(envelope.next_actions[0].command, "radroots order list"); + assert_eq!(envelope.next_actions[1].label, "basket create"); + assert_eq!(envelope.next_actions[1].command, "radroots basket create"); + } + + #[test] fn ndjson_frames_serialize_one_json_object_per_line() { let frames = [ NdjsonFrame::new(