cli

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

commit 3f7964c064e12aea3eb736547822abcfbdb66d0f
parent 99317d2f2c676f3186a6a5dae1aee2836b23be23
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 04:29:15 +0000

cli: expose listing relay delivery truth

- carry connected relay state through direct publish receipts
- return structured listing relay failure detail
- surface connected relays in listing mutation output
- cover listing relay failure detail in process tests

Diffstat:
Msrc/domain/runtime.rs | 18++++++++++++------
Msrc/operation_listing.rs | 21+++++++++++++++++++++
Msrc/runtime/direct_relay.rs | 40++++++++++++++++++++++++++++++++++------
Msrc/runtime/farm.rs | 2++
Msrc/runtime/listing.rs | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/runtime/order.rs | 20++++++++++++++++++++
Mtests/signer_runtime_modes.rs | 36++++++++++++++++++++++++++++++++++++
7 files changed, 237 insertions(+), 15 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -835,11 +835,13 @@ pub struct FarmPublishComponentView { pub rpc_method: String, pub event_kind: u32, pub deduplicated: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde(default)] pub target_relays: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub connected_relays: Vec<String>, + #[serde(default)] pub acknowledged_relays: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde(default)] pub failed_relays: Vec<RelayFailureView>, #[serde(skip_serializing_if = "Option::is_none")] pub job_id: Option<String>, @@ -1217,6 +1219,8 @@ pub struct OrderSubmitView { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub target_relays: Vec<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub connected_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub acknowledged_relays: Vec<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub failed_relays: Vec<RelayFailureView>, @@ -2572,11 +2576,13 @@ pub struct ListingMutationView { pub dry_run: bool, #[serde(default)] pub deduplicated: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde(default)] pub target_relays: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub connected_relays: Vec<String>, + #[serde(default)] pub acknowledged_relays: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde(default)] pub failed_relays: Vec<RelayFailureView>, #[serde(skip_serializing_if = "Option::is_none")] pub job_id: Option<String>, diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -238,6 +238,18 @@ where { match view.disposition() { CommandDisposition::Success => serialized_operation_result::<R, _>(view), + CommandDisposition::ExternalUnavailable if listing_relay_unavailable(view) => { + Err(OperationAdapterError::network_unavailable_with_detail( + operation_id, + view.reason.clone().unwrap_or_else(|| { + format!( + "listing {} finished with state `{}`", + view.operation, view.state + ) + }), + serde_json::to_value(view).unwrap_or(Value::Null), + )) + } disposition => Err(OperationAdapterError::from_command_disposition( operation_id, disposition, @@ -251,6 +263,15 @@ where } } +fn listing_relay_unavailable(view: &ListingMutationView) -> bool { + view.source == "direct Nostr relay publish ยท local key" + && (view.reason.as_deref().is_some_and(|reason| { + reason.contains("configured relay") || reason.contains("direct relay connection failed") + }) || !view.target_relays.is_empty() + || !view.connected_relays.is_empty() + || !view.failed_relays.is_empty()) +} + fn map_runtime<T>( operation_id: &str, result: Result<T, RuntimeError>, diff --git a/src/runtime/direct_relay.rs b/src/runtime/direct_relay.rs @@ -22,6 +22,7 @@ pub struct DirectRelayPublishReceipt { pub created_at: u32, pub signature: String, pub target_relays: Vec<String>, + pub connected_relays: Vec<String>, pub acknowledged_relays: Vec<String>, pub failed_relays: Vec<DirectRelayFailure>, } @@ -50,10 +51,21 @@ pub enum DirectRelayPublishError { #[source] source: RadrootsNostrError, }, - #[error("direct relay connection failed: {0}")] - Connect(String), + #[error("direct relay connection failed: {reason}")] + Connect { + reason: String, + target_relays: Vec<String>, + connected_relays: Vec<String>, + failed_relays: Vec<DirectRelayFailure>, + }, #[error("direct relay publish failed for event `{event_id}`: {reason}")] - Publish { event_id: String, reason: String }, + Publish { + event_id: String, + reason: String, + target_relays: Vec<String>, + connected_relays: Vec<String>, + failed_relays: Vec<DirectRelayFailure>, + }, } #[derive(Debug, thiserror::Error)] @@ -195,10 +207,19 @@ async fn publish_parts_with_identity_async( } let connection_output = client.try_connect(RELAY_CONNECT_TIMEOUT).await; + let connected_relays = connection_output + .success + .iter() + .map(ToString::to_string) + .collect::<Vec<_>>(); + let connection_failed_relays = relay_failures_from_output(&connection_output); if connection_output.success.is_empty() { - return Err(DirectRelayPublishError::Connect(summarize_failures( - &relay_failures_from_output(&connection_output), - ))); + return Err(DirectRelayPublishError::Connect { + reason: summarize_failures(&connection_failed_relays), + target_relays: relay_urls.to_vec(), + connected_relays, + failed_relays: connection_failed_relays, + }); } let publish_output = @@ -208,12 +229,18 @@ async fn publish_parts_with_identity_async( .map_err(|source| DirectRelayPublishError::Publish { event_id: event_id.clone(), reason: source.to_string(), + target_relays: relay_urls.to_vec(), + connected_relays: connected_relays.clone(), + failed_relays: Vec::new(), })?; let failed_relays = relay_failures_from_output(&publish_output); if publish_output.success.is_empty() { return Err(DirectRelayPublishError::Publish { event_id: event_id.clone(), reason: summarize_failures(&failed_relays), + target_relays: relay_urls.to_vec(), + connected_relays, + failed_relays, }); } @@ -222,6 +249,7 @@ async fn publish_parts_with_identity_async( created_at, signature, target_relays: relay_urls.to_vec(), + connected_relays, acknowledged_relays: publish_output .success .iter() diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -606,6 +606,7 @@ fn preview_component( event_kind, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), job_id: None, @@ -692,6 +693,7 @@ fn published_component( event_kind, deduplicated: false, target_relays: receipt.target_relays, + connected_relays: receipt.connected_relays, acknowledged_relays: receipt.acknowledged_relays, failed_relays: relay_failures(receipt.failed_relays), job_id: None, diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -35,7 +35,8 @@ use crate::runtime::RuntimeError; use crate::runtime::accounts; use crate::runtime::config::{PublishMode, RuntimeConfig, SignerBackend}; use crate::runtime::direct_relay::{ - DirectRelayFailure, DirectRelayPublishReceipt, publish_parts_with_identity, + DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt, + publish_parts_with_identity, }; use crate::runtime::farm_config; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; @@ -895,6 +896,7 @@ fn mutate( dry_run: true, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), job_id: None, @@ -987,8 +989,27 @@ fn mutate_via_direct_relay( }; let receipt = - publish_parts_with_identity(&signing.identity, &config.relay.urls, event_draft.parts) - .map_err(|error| RuntimeError::Network(error.to_string()))?; + match publish_parts_with_identity(&signing.identity, &config.relay.urls, event_draft.parts) + { + Ok(receipt) => receipt, + Err( + error @ (DirectRelayPublishError::MissingRelays + | DirectRelayPublishError::RelayConfig { .. } + | DirectRelayPublishError::Connect { .. } + | DirectRelayPublishError::Publish { .. }), + ) => { + return Ok(direct_relay_error_view( + config, + args, + operation, + canonical, + listing_addr, + event_draft.event, + error, + )); + } + Err(error) => return Err(RuntimeError::Network(error.to_string())), + }; Ok(published_mutation_view( config, @@ -1479,6 +1500,7 @@ fn direct_relay_unavailable_view( dry_run: false, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), job_id: None, @@ -1516,6 +1538,7 @@ fn radrootsd_unavailable_view( dry_run: false, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), job_id: None, @@ -1533,6 +1556,89 @@ fn radrootsd_unavailable_view( } } +fn direct_relay_error_view( + config: &RuntimeConfig, + args: &ListingMutationArgs, + operation: ListingMutationOperation, + canonical: &CanonicalListingDraft, + listing_addr: String, + event_preview: ListingMutationEventView, + error: DirectRelayPublishError, +) -> ListingMutationView { + let (reason, target_relays, connected_relays, failed_relays) = match error { + DirectRelayPublishError::MissingRelays => ( + "direct relay publish requires at least one configured relay".to_owned(), + config.relay.urls.clone(), + Vec::new(), + Vec::new(), + ), + DirectRelayPublishError::RelayConfig { relay, source } => ( + format!("failed to configure relay `{relay}` for direct relay publish: {source}"), + config.relay.urls.clone(), + Vec::new(), + vec![RelayFailureView { + relay, + reason: source.to_string(), + }], + ), + DirectRelayPublishError::Connect { + reason, + target_relays, + connected_relays, + failed_relays, + } => ( + format!("direct relay connection failed: {reason}"), + target_relays, + connected_relays, + relay_failures(failed_relays), + ), + DirectRelayPublishError::Publish { + event_id, + reason, + target_relays, + connected_relays, + failed_relays, + } => ( + format!("direct relay publish failed for event `{event_id}`: {reason}"), + target_relays, + connected_relays, + relay_failures(failed_relays), + ), + DirectRelayPublishError::Runtime(_) + | DirectRelayPublishError::Build(_) + | DirectRelayPublishError::Sign(_) => unreachable!(), + }; + + ListingMutationView { + state: "unavailable".to_owned(), + operation: operation.as_str().to_owned(), + source: listing_write_source(config).to_owned(), + file: args.file.display().to_string(), + listing_id: canonical.listing_id.clone(), + listing_addr: listing_addr.clone(), + seller_pubkey: canonical.seller_pubkey.clone(), + event_kind: KIND_LISTING, + dry_run: false, + deduplicated: false, + target_relays, + connected_relays, + acknowledged_relays: Vec::new(), + failed_relays, + job_id: None, + job_status: None, + signer_mode: Some(config.signer.backend.as_str().to_owned()), + event_id: None, + event_addr: Some(listing_addr), + idempotency_key: args.idempotency_key.clone(), + signer_session_id: None, + requested_signer_session_id: args.signer_session_id.clone(), + reason: Some(reason), + job: None, + event: args.print_event.then_some(event_preview), + actions: Vec::new(), + } +} + fn validate_local_listing_signer( config: &RuntimeConfig, canonical: &CanonicalListingDraft, @@ -1586,6 +1692,7 @@ fn binding_error_view( dry_run: false, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), job_id: None, @@ -1617,6 +1724,7 @@ fn published_mutation_view( created_at, signature, target_relays, + connected_relays, acknowledged_relays, failed_relays, } = receipt; @@ -1639,6 +1747,7 @@ fn published_mutation_view( dry_run: false, deduplicated: false, target_relays, + connected_relays, acknowledged_relays, failed_relays: relay_failures(failed_relays), job_id: None, diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -569,6 +569,7 @@ pub fn submit( dry_run: config.output.dry_run, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), @@ -604,6 +605,7 @@ pub fn submit( dry_run: config.output.dry_run, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), @@ -641,6 +643,7 @@ pub fn submit( dry_run: config.output.dry_run, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), @@ -7423,6 +7426,7 @@ fn published_order_revision_view( created_at: _, signature: _, target_relays, + connected_relays, acknowledged_relays, failed_relays, } = receipt; @@ -7432,6 +7436,7 @@ fn published_order_revision_view( view.event_id = Some(event_id); view.event_kind = Some(event_kind); view.target_relays = target_relays; + view.connected_relays = connected_relays; view.acknowledged_relays = acknowledged_relays; view.failed_relays = relay_failures(failed_relays); view @@ -7451,6 +7456,7 @@ fn published_order_revision_decision_view( created_at: _, signature: _, target_relays, + connected_relays: _, acknowledged_relays, failed_relays, } = receipt; @@ -7576,6 +7582,7 @@ fn published_order_fulfillment_view( created_at: _, signature: _, target_relays, + connected_relays: _, acknowledged_relays, failed_relays, } = receipt; @@ -7603,6 +7610,7 @@ fn published_order_cancellation_view( created_at: _, signature: _, target_relays, + connected_relays: _, acknowledged_relays, failed_relays, } = receipt; @@ -7629,6 +7637,7 @@ fn published_order_receipt_view( created_at: _, signature: _, target_relays, + connected_relays: _, acknowledged_relays, failed_relays, } = receipt; @@ -7663,6 +7672,7 @@ fn published_order_payment_view( created_at: _, signature: _, target_relays, + connected_relays: _, acknowledged_relays, failed_relays, } = receipt; @@ -7690,6 +7700,7 @@ fn published_order_settlement_view( created_at: _, signature: _, target_relays, + connected_relays: _, acknowledged_relays, failed_relays, } = receipt; @@ -8048,6 +8059,7 @@ fn published_order_decision_view( created_at: _, signature: _, target_relays, + connected_relays: _, acknowledged_relays, failed_relays, } = receipt; @@ -9273,6 +9285,7 @@ fn order_submit_unconfigured_view( dry_run: config.output.dry_run, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), @@ -9309,6 +9322,7 @@ fn order_submit_invalid_quantity_view( dry_run: config.output.dry_run, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), @@ -9544,6 +9558,7 @@ fn order_submit_deduplicated_view( dry_run: config.output.dry_run, deduplicated: true, target_relays, + connected_relays: connected_relays.clone(), acknowledged_relays: connected_relays, failed_relays: relay_failures(failed_relays), idempotency_key: args.idempotency_key.clone(), @@ -9581,6 +9596,7 @@ fn order_submit_dry_run_view( dry_run: true, deduplicated: false, target_relays: config.relay.urls.clone(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), @@ -9624,6 +9640,7 @@ fn order_submit_invalid_existing_request_view( dry_run: config.output.dry_run, deduplicated: false, target_relays, + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: relay_failures(failed_relays), idempotency_key: args.idempotency_key.clone(), @@ -9703,6 +9720,7 @@ fn published_order_submit_view( created_at: _, signature: _, target_relays, + connected_relays, acknowledged_relays, failed_relays, } = receipt; @@ -9723,6 +9741,7 @@ fn published_order_submit_view( dry_run: false, deduplicated: false, target_relays, + connected_relays, acknowledged_relays, failed_relays: relay_failures(failed_relays), idempotency_key: args.idempotency_key.clone(), @@ -9766,6 +9785,7 @@ fn order_binding_error_view( dry_run: config.output.dry_run, deduplicated: false, target_relays: Vec::new(), + connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -1003,6 +1003,24 @@ fn local_seller_publish_commands_attempt_configured_direct_relay() { "listing.publish", &["listing", "publish"], ); + assert_eq!( + publish_value["errors"][0]["detail"]["target_relays"][0], + relay + ); + assert_eq!( + publish_value["errors"][0]["detail"]["connected_relays"] + .as_array() + .expect("connected relays") + .len(), + 0 + ); + assert_eq!( + publish_value["errors"][0]["detail"]["failed_relays"] + .as_array() + .expect("failed relays") + .len(), + 1 + ); let (archive_output, archive_value) = sandbox.json_output(&[ "--format", @@ -1021,6 +1039,24 @@ fn local_seller_publish_commands_attempt_configured_direct_relay() { "listing.archive", &["listing", "archive"], ); + assert_eq!( + archive_value["errors"][0]["detail"]["target_relays"][0], + relay + ); + assert_eq!( + archive_value["errors"][0]["detail"]["connected_relays"] + .as_array() + .expect("connected relays") + .len(), + 0 + ); + assert_eq!( + archive_value["errors"][0]["detail"]["failed_relays"] + .as_array() + .expect("failed relays") + .len(), + 1 + ); seed_orderable_listing(&sandbox, LISTING_ADDR); sandbox.json_success(&["--format", "json", "basket", "create", "direct_order"]);