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:
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}`"
+ );
+}