commit adadcdc5097eb604be12e0082f1911afe3cea074
parent 0f296d15ed0294fb71749d1a7cf92783814dc8bd
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 23:00:16 +0000
cli: publish farms through direct relays
- wire farm publish to the local-key relay publish helper
- return farm publish event ids with relay delivery fields
- map direct relay transport failures to network errors
- remove the farm publish unavailable path from active execution
Diffstat:
5 files changed, 160 insertions(+), 95 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -792,6 +792,12 @@ pub struct FarmPublishComponentView {
pub rpc_method: String,
pub event_kind: u32,
pub deduplicated: bool,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub target_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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -843,6 +849,12 @@ pub struct FarmPublishEventView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct RelayFailureView {
+ pub relay: String,
+ pub reason: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct FarmConfigSummaryView {
pub scope: String,
pub path: String,
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -467,6 +467,10 @@ impl OperationAdapterError {
message,
}
}
+ RuntimeError::Network(_) => Self::NetworkUnavailable {
+ operation_id: operation_id.to_owned(),
+ message,
+ },
RuntimeError::Accounts(_) => classify_runtime_failure(
operation_id,
message,
@@ -1424,6 +1428,15 @@ mod tests {
"account",
5,
),
+ (
+ OperationAdapterError::runtime_failure(
+ "farm.publish",
+ RuntimeError::Network("direct relay connection failed".to_owned()),
+ ),
+ "network_unavailable",
+ "network",
+ 8,
+ ),
];
for (error, code, class, exit_code) in cases {
diff --git a/src/runtime/direct_relay.rs b/src/runtime/direct_relay.rs
@@ -3,8 +3,7 @@ use std::time::Duration;
use radroots_events_codec::wire::WireEventParts;
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::{
- RadrootsNostrClient, RadrootsNostrError, RadrootsNostrEvent, RadrootsNostrOutput,
- radroots_nostr_build_event,
+ RadrootsNostrClient, RadrootsNostrError, RadrootsNostrOutput, radroots_nostr_build_event,
};
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -17,7 +16,6 @@ pub struct DirectRelayFailure {
#[derive(Debug, Clone)]
pub struct DirectRelayPublishReceipt {
- pub event: RadrootsNostrEvent,
pub event_id: String,
pub target_relays: Vec<String>,
pub acknowledged_relays: Vec<String>,
@@ -111,7 +109,6 @@ async fn publish_parts_with_identity_async(
}
Ok(DirectRelayPublishReceipt {
- event,
event_id,
target_relays: relay_urls.to_vec(),
acknowledged_relays: publish_output
@@ -148,14 +145,6 @@ fn summarize_failures(failed_relays: &[DirectRelayFailure]) -> String {
.join("; ")
}
-pub fn event_created_at_u32(event: &RadrootsNostrEvent) -> u32 {
- u32::try_from(event.created_at.as_secs()).unwrap_or(u32::MAX)
-}
-
-pub fn event_signature(event: &RadrootsNostrEvent) -> String {
- event.sig.to_string()
-}
-
#[cfg(test)]
mod tests {
use radroots_events_codec::wire::WireEventParts;
diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs
@@ -8,15 +8,19 @@ use radroots_events::profile::{RadrootsProfile, RadrootsProfileType};
use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::farm::encode::to_wire_parts_with_kind;
use radroots_events_codec::profile::encode::to_wire_parts_with_profile_type;
+use radroots_events_codec::wire::WireEventParts;
use crate::domain::runtime::{
FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView,
FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishView,
- FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView,
+ FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView, RelayFailureView,
};
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,
+};
use crate::runtime::farm_config::{
self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults,
FarmMissingField, FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION,
@@ -27,9 +31,7 @@ use crate::runtime_args::{
};
const FARM_CONFIG_SOURCE: &str = "farm config · local first";
-const FARM_WRITE_SOURCE: &str = "direct Nostr relay publish · pending implementation";
-const DIRECT_RELAY_UNAVAILABLE_REASON: &str =
- "direct Nostr relay publishing is not implemented for farm publish";
+const FARM_WRITE_SOURCE: &str = "direct Nostr relay publish · local key";
static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -347,18 +349,21 @@ pub fn publish(
let profile_idempotency_key = component_idempotency_key(args, "profile")?;
let farm_idempotency_key = component_idempotency_key(args, "farm")?;
- if let Err(error) = resolve_farm_write_authority(config, account_pubkey.as_str()) {
- return Ok(binding_error_publish_view(
- config,
- args,
- &resolved,
- &account_pubkey,
- previews,
- profile_idempotency_key,
- farm_idempotency_key,
- error,
- ));
- }
+ let signing = match resolve_farm_signing_identity(config, account_pubkey.as_str()) {
+ Ok(signing) => signing,
+ Err(error) => {
+ return Ok(binding_error_publish_view(
+ config,
+ args,
+ &resolved,
+ &account_pubkey,
+ previews,
+ profile_idempotency_key,
+ farm_idempotency_key,
+ error,
+ ));
+ }
+ };
if config.output.dry_run {
return Ok(base_publish_view(
@@ -372,14 +377,14 @@ pub fn publish(
KIND_PROFILE,
profile_idempotency_key,
args,
- Some(previews.profile),
+ Some(previews.profile.event),
),
preview_component(
"relay.farm.publish",
KIND_FARM,
farm_idempotency_key,
args,
- Some(previews.farm),
+ Some(previews.farm.event),
),
Some("dry run requested; relay publish skipped".to_owned()),
vec![format!(
@@ -388,21 +393,53 @@ pub fn publish(
)],
));
}
- Ok(direct_relay_unavailable_publish_view(
+ let profile_receipt = publish_parts_with_identity(
+ &signing.identity,
+ &config.relay.urls,
+ 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()))?;
+
+ Ok(base_publish_view(
+ "published",
config,
args,
&resolved,
&account_pubkey,
- previews,
- profile_idempotency_key,
- farm_idempotency_key,
+ published_component(
+ "relay.profile.publish",
+ KIND_PROFILE,
+ profile_idempotency_key,
+ args,
+ previews.profile.event,
+ profile_receipt,
+ ),
+ published_component(
+ "relay.farm.publish",
+ KIND_FARM,
+ farm_idempotency_key,
+ args,
+ previews.farm.event,
+ farm_receipt,
+ ),
+ None,
+ Vec::new(),
))
}
#[derive(Debug, Clone)]
struct FarmPublishPreviews {
- profile: FarmPublishEventView,
- farm: FarmPublishEventView,
+ profile: FarmPublishEventDraft,
+ farm: FarmPublishEventDraft,
+}
+
+#[derive(Debug, Clone)]
+struct FarmPublishEventDraft {
+ event: FarmPublishEventView,
+ parts: WireEventParts,
}
fn missing_publish_view(
@@ -437,15 +474,19 @@ fn missing_publish_view(
}
}
-fn resolve_farm_write_authority(
+fn resolve_farm_signing_identity(
config: &RuntimeConfig,
account_pubkey: &str,
-) -> Result<Option<crate::runtime::signer::ActorWriteSignerAuthority>, ActorWriteBindingError> {
+) -> Result<accounts::AccountSigningIdentity, ActorWriteBindingError> {
if !matches!(
config.signer.backend,
crate::runtime::config::SignerBackend::Local
) {
- return resolve_actor_write_authority(config, "farm", account_pubkey);
+ return resolve_actor_write_authority(config, "farm", account_pubkey).and_then(|_| {
+ Err(ActorWriteBindingError::Unconfigured(
+ "farm publish requires signer mode `local`".to_owned(),
+ ))
+ });
}
let signing = accounts::resolve_local_signing_identity(config)
.map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?;
@@ -460,7 +501,7 @@ fn resolve_farm_write_authority(
"selected local account pubkey `{selected_pubkey}` cannot sign farm pubkey `{account_pubkey}`"
)));
}
- Ok(None)
+ Ok(signing)
}
fn base_publish_view(
@@ -508,21 +549,27 @@ fn build_publish_previews(
);
Ok(FarmPublishPreviews {
- profile: FarmPublishEventView {
- kind: profile_parts.kind,
- author: account_pubkey.to_owned(),
- content: profile_parts.content,
- tags: profile_parts.tags,
- event_id: None,
- event_addr: None,
+ profile: FarmPublishEventDraft {
+ event: FarmPublishEventView {
+ kind: profile_parts.kind,
+ author: account_pubkey.to_owned(),
+ content: profile_parts.content.clone(),
+ tags: profile_parts.tags.clone(),
+ event_id: None,
+ event_addr: None,
+ },
+ parts: profile_parts,
},
- farm: FarmPublishEventView {
- kind: farm_parts.kind,
- author: account_pubkey.to_owned(),
- content: farm_parts.content,
- tags: farm_parts.tags,
- event_id: None,
- event_addr: Some(farm_addr),
+ farm: FarmPublishEventDraft {
+ event: FarmPublishEventView {
+ kind: farm_parts.kind,
+ author: account_pubkey.to_owned(),
+ content: farm_parts.content.clone(),
+ tags: farm_parts.tags.clone(),
+ event_id: None,
+ event_addr: Some(farm_addr),
+ },
+ parts: farm_parts,
},
})
}
@@ -555,6 +602,9 @@ fn preview_component(
rpc_method: rpc_method.to_owned(),
event_kind,
deduplicated: false,
+ target_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
job_id: None,
job_status: None,
signer_mode: None,
@@ -609,7 +659,7 @@ fn binding_error_publish_view(
KIND_PROFILE,
profile_idempotency_key,
args,
- Some(previews.profile),
+ Some(previews.profile.event),
)
},
FarmPublishComponentView {
@@ -620,7 +670,7 @@ fn binding_error_publish_view(
KIND_FARM,
farm_idempotency_key,
args,
- Some(previews.farm),
+ Some(previews.farm.event),
)
},
Some(reason),
@@ -628,46 +678,44 @@ fn binding_error_publish_view(
)
}
-fn direct_relay_unavailable_publish_view(
- config: &RuntimeConfig,
+fn published_component(
+ rpc_method: &str,
+ event_kind: u32,
+ idempotency_key: Option<String>,
args: &FarmPublishArgs,
- resolved: &ResolvedFarmConfig,
- account_pubkey: &str,
- previews: FarmPublishPreviews,
- profile_idempotency_key: Option<String>,
- farm_idempotency_key: Option<String>,
-) -> FarmPublishView {
- base_publish_view(
- "unavailable",
- config,
- args,
- resolved,
- account_pubkey,
- FarmPublishComponentView {
- state: "unavailable".to_owned(),
- reason: Some(DIRECT_RELAY_UNAVAILABLE_REASON.to_owned()),
- ..preview_component(
- "relay.profile.publish",
- KIND_PROFILE,
- profile_idempotency_key,
- args,
- Some(previews.profile),
- )
- },
- FarmPublishComponentView {
- state: "unavailable".to_owned(),
- reason: Some(DIRECT_RELAY_UNAVAILABLE_REASON.to_owned()),
- ..preview_component(
- "relay.farm.publish",
- KIND_FARM,
- farm_idempotency_key,
- args,
- Some(previews.farm),
- )
- },
- Some(DIRECT_RELAY_UNAVAILABLE_REASON.to_owned()),
- Vec::new(),
- )
+ mut event: FarmPublishEventView,
+ receipt: DirectRelayPublishReceipt,
+) -> FarmPublishComponentView {
+ event.event_id = Some(receipt.event_id.clone());
+ FarmPublishComponentView {
+ state: "published".to_owned(),
+ rpc_method: rpc_method.to_owned(),
+ event_kind,
+ deduplicated: false,
+ target_relays: receipt.target_relays,
+ acknowledged_relays: receipt.acknowledged_relays,
+ failed_relays: relay_failures(receipt.failed_relays),
+ job_id: None,
+ job_status: None,
+ signer_mode: Some("local".to_owned()),
+ signer_session_id: None,
+ event_id: Some(receipt.event_id),
+ event_addr: event.event_addr.clone(),
+ idempotency_key,
+ reason: None,
+ job: None,
+ event: args.print_event.then_some(event),
+ }
+}
+
+fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> {
+ failures
+ .into_iter()
+ .map(|failure| RelayFailureView {
+ relay: failure.relay,
+ reason: failure.reason,
+ })
+ .collect()
}
fn selected_account_for_draft(
diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs
@@ -29,6 +29,8 @@ pub enum RuntimeError {
Sql(#[from] radroots_replica_db::SqlError),
#[error("replica sync error: {0}")]
ReplicaSync(#[from] radroots_replica_sync::RadrootsReplicaEventsError),
+ #[error("network error: {0}")]
+ Network(String),
#[error("failed to serialize json output: {0}")]
Json(#[from] serde_json::Error),
#[error("failed to write output: {0}")]
@@ -43,6 +45,7 @@ impl RuntimeError {
| Self::Accounts(_)
| Self::Sql(_)
| Self::ReplicaSync(_)
+ | Self::Network(_)
| Self::Json(_)
| Self::Io(_) => ExitCode::from(1),
}