cli

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

commit 168211291c366a966907d8dcca0080f141ad7900
parent 9cf6611475710d3eadca86ac7c3be9afed6241b6
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 17:16:18 +0000

farm: preserve partial publish truth

- return structured partial output after profile publish succeeds
- keep farm publish failure details in the relay component
- expose partial farm publish state through network failure detail
- prove profile-success farm-failure behavior with a local relay test

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Msrc/operation_farm.rs | 16++++++++++++++++
Msrc/runtime/farm.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/signer_runtime_modes.rs | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 337 insertions(+), 4 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1879,6 +1879,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml", + "tungstenite", "url", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml @@ -54,3 +54,4 @@ assert_cmd = "2.0" flate2 = "1" tar = "0.4" tempfile = "3.17" +tungstenite = "0.26.2" diff --git a/src/operation_farm.rs b/src/operation_farm.rs @@ -221,6 +221,15 @@ fn farm_publish_result( ) -> Result<OperationResult<FarmPublishResult>, OperationAdapterError> { match view.disposition() { CommandDisposition::Success => serialized_operation_result::<FarmPublishResult, _>(view), + CommandDisposition::ExternalUnavailable if farm_publish_relay_unavailable(view) => { + Err(OperationAdapterError::network_unavailable_with_detail( + operation_id, + view.reason.clone().unwrap_or_else(|| { + format!("farm publish finished with state `{}`", view.state) + }), + serde_json::to_value(view).unwrap_or(Value::Null), + )) + } disposition => Err(OperationAdapterError::from_command_disposition( operation_id, disposition, @@ -237,6 +246,13 @@ fn farm_publish_result( } } +fn farm_publish_relay_unavailable(view: &FarmPublishView) -> bool { + view.source == "direct Nostr relay publish ยท local key" + && (!view.profile.failed_relays.is_empty() + || !view.farm.failed_relays.is_empty() + || view.state == "partial") +} + fn require_relay_target<P>( request: &OperationRequest<P>, config: &RuntimeConfig, diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -19,7 +19,8 @@ use crate::runtime::RuntimeError; use crate::runtime::accounts::{self, AccountRecordView}; use crate::runtime::config::RuntimeConfig; use crate::runtime::direct_relay::{ - DirectRelayFailure, DirectRelayPublishReceipt, publish_parts_with_identity, + DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt, + publish_parts_with_identity, }; use crate::runtime::farm_config::{ self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults, @@ -400,9 +401,26 @@ pub fn publish( previews.profile.parts.clone(), ) .map_err(|error| RuntimeError::Network(error.to_string()))?; - let farm_receipt = - publish_parts_with_identity(&signing.identity, &config.relay.urls, previews.farm.parts) - .map_err(|error| RuntimeError::Network(error.to_string()))?; + let farm_receipt = match publish_parts_with_identity( + &signing.identity, + &config.relay.urls, + previews.farm.parts.clone(), + ) { + Ok(receipt) => receipt, + Err(error) => { + return Ok(partial_publish_view( + config, + args, + &resolved, + &account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + profile_receipt, + error, + )); + } + }; Ok(base_publish_view( "published", @@ -678,6 +696,49 @@ fn binding_error_publish_view( ) } +fn partial_publish_view( + config: &RuntimeConfig, + args: &FarmPublishArgs, + resolved: &ResolvedFarmConfig, + account_pubkey: &str, + previews: FarmPublishPreviews, + profile_idempotency_key: Option<String>, + farm_idempotency_key: Option<String>, + profile_receipt: DirectRelayPublishReceipt, + farm_error: DirectRelayPublishError, +) -> FarmPublishView { + let reason = format!("farm publish failed after profile publish: {farm_error}"); + base_publish_view( + "partial", + config, + args, + resolved, + account_pubkey, + published_component( + "relay.profile.publish", + KIND_PROFILE, + profile_idempotency_key, + args, + previews.profile.event, + profile_receipt, + ), + failed_component( + "relay.farm.publish", + KIND_FARM, + farm_idempotency_key, + args, + previews.farm.event, + &config.relay.urls, + farm_error, + ), + Some(reason), + vec![format!( + "radroots farm publish --scope {}", + resolved.scope.as_str() + )], + ) +} + fn published_component( rpc_method: &str, event_kind: u32, @@ -709,6 +770,96 @@ fn published_component( } } +fn failed_component( + rpc_method: &str, + event_kind: u32, + idempotency_key: Option<String>, + args: &FarmPublishArgs, + event: FarmPublishEventView, + relay_urls: &[String], + error: DirectRelayPublishError, +) -> FarmPublishComponentView { + let reason = error.to_string(); + let failure = publish_failure_details(error, relay_urls); + FarmPublishComponentView { + state: "failed".to_owned(), + rpc_method: rpc_method.to_owned(), + event_kind, + deduplicated: false, + target_relays: failure.target_relays, + connected_relays: failure.connected_relays, + acknowledged_relays: Vec::new(), + failed_relays: failure.failed_relays, + job_id: None, + job_status: None, + signer_mode: Some("local".to_owned()), + signer_session_id: None, + event_id: failure.event_id, + event_addr: event.event_addr.clone(), + idempotency_key, + reason: Some(reason), + job: None, + event: args.print_event.then_some(event), + } +} + +#[derive(Debug, Clone)] +struct FarmPublishFailureDetails { + event_id: Option<String>, + target_relays: Vec<String>, + connected_relays: Vec<String>, + failed_relays: Vec<RelayFailureView>, +} + +fn publish_failure_details( + error: DirectRelayPublishError, + relay_urls: &[String], +) -> FarmPublishFailureDetails { + match error { + DirectRelayPublishError::MissingRelays + | DirectRelayPublishError::Runtime(_) + | DirectRelayPublishError::Build(_) + | DirectRelayPublishError::Sign(_) => FarmPublishFailureDetails { + event_id: None, + target_relays: relay_urls.to_vec(), + connected_relays: Vec::new(), + failed_relays: Vec::new(), + }, + DirectRelayPublishError::RelayConfig { relay, source } => FarmPublishFailureDetails { + event_id: None, + target_relays: relay_urls.to_vec(), + connected_relays: Vec::new(), + failed_relays: vec![RelayFailureView { + relay, + reason: source.to_string(), + }], + }, + DirectRelayPublishError::Connect { + target_relays, + connected_relays, + failed_relays, + .. + } => FarmPublishFailureDetails { + event_id: None, + target_relays, + connected_relays, + failed_relays: relay_failures(failed_relays), + }, + DirectRelayPublishError::Publish { + event_id, + target_relays, + connected_relays, + failed_relays, + .. + } => FarmPublishFailureDetails { + event_id: Some(event_id), + target_relays, + connected_relays, + failed_relays: relay_failures(failed_relays), + }, + } +} + fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> { failures .into_iter() diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -1,8 +1,14 @@ mod support; use std::fs; +use std::net::{TcpListener, TcpStream}; use std::path::Path; +use std::sync::mpsc::{self, Receiver}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; +use radroots_events::kinds::{KIND_FARM, KIND_PROFILE}; +use serde_json::{Value, json}; use support::{ RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft, identity_public, identity_secret, @@ -13,6 +19,79 @@ use support::{ const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; +struct FarmPartialRelayServer { + endpoint: String, + requests: Receiver<Value>, + handle: JoinHandle<()>, +} + +impl FarmPartialRelayServer { + fn profile_accept_farm_reject() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind relay"); + let endpoint = format!("ws://{}", listener.local_addr().expect("relay addr")); + let (tx, requests) = mpsc::channel(); + let handle = thread::spawn(move || { + for (accepted, reason) in [(true, ""), (false, "farm rejected by test relay")] { + let (stream, _) = listener.accept().expect("accept relay connection"); + handle_publish_connection(stream, accepted, reason, &tx); + } + }); + + Self { + endpoint, + requests, + handle, + } + } + + fn endpoint(&self) -> &str { + self.endpoint.as_str() + } + + fn take_requests(self) -> Vec<Value> { + let requests = (0..2) + .map(|_| { + self.requests + .recv_timeout(Duration::from_secs(5)) + .expect("relay publish request") + }) + .collect::<Vec<_>>(); + self.handle.join().expect("relay server join"); + requests + } +} + +fn handle_publish_connection( + stream: TcpStream, + accepted: bool, + reason: &str, + tx: &mpsc::Sender<Value>, +) { + let mut websocket = tungstenite::accept(stream).expect("accept websocket"); + let event = read_event_message(&mut websocket); + let event_id = event["id"].as_str().expect("event id").to_owned(); + tx.send(event).expect("relay request send"); + websocket + .send(tungstenite::Message::Text( + json!(["OK", event_id, accepted, reason]).to_string().into(), + )) + .expect("relay ok send"); +} + +fn read_event_message(websocket: &mut tungstenite::WebSocket<TcpStream>) -> Value { + loop { + let message = websocket.read().expect("relay message"); + if !message.is_text() { + continue; + } + let value: Value = + serde_json::from_str(message.to_text().expect("relay text")).expect("relay json"); + if value.get(0).and_then(Value::as_str) == Some("EVENT") { + return value.get(1).cloned().expect("relay event payload"); + } + } +} + #[test] fn local_signer_status_reports_unconfigured_without_account() { let sandbox = RadrootsCliSandbox::new(); @@ -1001,6 +1080,83 @@ fn local_farm_publish_fails_without_configured_relay() { } #[test] +fn local_farm_publish_reports_partial_when_farm_event_fails_after_profile_publish() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let relay = FarmPartialRelayServer::profile_accept_farm_reject(); + let relay_url = relay.endpoint().to_owned(); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--relay", + relay_url.as_str(), + "--approval-token", + "approve", + "--idempotency-key", + "farm_partial", + "farm", + "publish", + ]); + let requests = relay.take_requests(); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "farm.publish"); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "network_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "network"); + assert_contains( + &value["errors"][0]["message"], + "farm publish failed after profile publish", + ); + assert_contains( + &value["errors"][0]["message"], + "farm rejected by test relay", + ); + let detail = &value["errors"][0]["detail"]; + assert_eq!(detail["state"], "partial"); + assert_eq!(detail["profile"]["state"], "published"); + assert_eq!(detail["farm"]["state"], "failed"); + assert_eq!(detail["profile"]["event_id"], requests[0]["id"]); + assert_eq!(detail["farm"]["event_id"], requests[1]["id"]); + assert_eq!(detail["profile"]["idempotency_key"], "farm_partial:profile"); + assert_eq!(detail["farm"]["idempotency_key"], "farm_partial:farm"); + assert_eq!(detail["profile"]["target_relays"][0], relay_url.as_str()); + assert_eq!(detail["farm"]["target_relays"][0], relay_url.as_str()); + assert_relay_url( + &detail["profile"]["acknowledged_relays"][0], + relay_url.as_str(), + ); + assert_relay_url(&detail["farm"]["connected_relays"][0], relay_url.as_str()); + assert_relay_url( + &detail["farm"]["failed_relays"][0]["relay"], + relay_url.as_str(), + ); + assert_contains( + &detail["farm"]["failed_relays"][0]["reason"], + "farm rejected by test relay", + ); + assert_eq!(requests[0]["kind"], KIND_PROFILE); + assert_eq!(requests[1]["kind"], KIND_FARM); + assert_no_removed_command_reference(&value, &["farm", "publish"]); + assert_no_daemon_runtime_reference(&value, &["farm", "publish"]); +} + +#[test] fn local_seller_publish_commands_attempt_configured_direct_relay() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); @@ -1467,3 +1623,11 @@ fn assert_direct_relay_connection_failure( assert_no_removed_command_reference(value, args); assert_no_daemon_runtime_reference(value, args); } + +fn assert_relay_url(value: &Value, relay_url: &str) { + let actual = value.as_str().expect("relay url"); + assert!( + actual == relay_url || actual == format!("{relay_url}/"), + "expected relay url `{actual}` to match `{relay_url}`" + ); +}