commit 5399882fe1e6070c061849730d6a9e233292a6d4
parent 5597fc481c64f1c72f5e77977e1c088768e7d88e
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Apr 2026 01:31:55 +0000
cli: add farm publish
Diffstat:
7 files changed, 1086 insertions(+), 13 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -85,6 +85,7 @@ impl Command {
},
Self::Doctor => "doctor",
Self::Farm(farm) => match farm.command {
+ FarmCommand::Publish(_) => "farm publish",
FarmCommand::Setup(_) => "farm setup",
FarmCommand::Status(_) => "farm status",
FarmCommand::Get(_) => "farm get",
@@ -271,12 +272,27 @@ pub struct FarmArgs {
#[derive(Debug, Clone, Subcommand)]
pub enum FarmCommand {
+ Publish(FarmPublishArgs),
Setup(FarmSetupArgs),
Status(FarmScopedArgs),
Get(FarmScopedArgs),
}
#[derive(Debug, Clone, Args, Default)]
+pub struct FarmPublishArgs {
+ #[arg(long, value_enum)]
+ pub scope: Option<FarmScopeArg>,
+ #[arg(long = "idempotency-key")]
+ pub idempotency_key: Option<String>,
+ #[arg(long = "signer-session-id")]
+ pub signer_session_id: Option<String>,
+ #[arg(long = "print-job", action = ArgAction::SetTrue)]
+ pub print_job: bool,
+ #[arg(long = "print-event", action = ArgAction::SetTrue)]
+ pub print_event: bool,
+}
+
+#[derive(Debug, Clone, Args, Default)]
pub struct FarmScopedArgs {
#[arg(long, value_enum)]
pub scope: Option<FarmScopeArg>,
@@ -796,6 +812,33 @@ mod tests {
_ => panic!("unexpected command variant"),
}
+ let farm_publish = CliArgs::parse_from([
+ "radroots",
+ "farm",
+ "publish",
+ "--scope",
+ "workspace",
+ "--idempotency-key",
+ "farm-publish-1",
+ "--signer-session-id",
+ "session-1",
+ "--print-job",
+ "--print-event",
+ ]);
+ match farm_publish.command {
+ Command::Farm(args) => match args.command {
+ FarmCommand::Publish(publish) => {
+ assert_eq!(publish.scope, Some(FarmScopeArg::Workspace));
+ assert_eq!(publish.idempotency_key.as_deref(), Some("farm-publish-1"));
+ assert_eq!(publish.signer_session_id.as_deref(), Some("session-1"));
+ assert!(publish.print_job);
+ assert!(publish.print_event);
+ }
+ _ => panic!("unexpected farm subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
let relay = CliArgs::parse_from(["radroots", "relay", "ls"]);
match relay.command {
Command::Relay(args) => match args.command {
@@ -1220,6 +1263,10 @@ mod tests {
.supports_output_format(OutputFormat::Ndjson)
);
+ let farm_publish = CliArgs::parse_from(["radroots", "farm", "publish"]);
+ assert_eq!(farm_publish.command.display_name(), "farm publish");
+ assert!(farm_publish.command.supports_dry_run());
+
let find = CliArgs::parse_from(["radroots", "find", "eggs"]);
assert!(find.command.supports_output_format(OutputFormat::Ndjson));
diff --git a/src/commands/farm.rs b/src/commands/farm.rs
@@ -1,6 +1,7 @@
-use crate::cli::{FarmScopedArgs, FarmSetupArgs};
+use crate::cli::{FarmPublishArgs, FarmScopedArgs, FarmSetupArgs};
use crate::domain::runtime::{
- CommandDisposition, CommandOutput, CommandView, FarmGetView, FarmSetupView, FarmStatusView,
+ CommandDisposition, CommandOutput, CommandView, FarmGetView, FarmPublishView, FarmSetupView,
+ FarmStatusView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
@@ -10,6 +11,14 @@ pub fn setup(config: &RuntimeConfig, args: &FarmSetupArgs) -> Result<CommandOutp
Ok(farm_setup_output(view))
}
+pub fn publish(
+ config: &RuntimeConfig,
+ args: &FarmPublishArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::farm::publish(config, args)?;
+ Ok(farm_publish_output(view))
+}
+
pub fn status(
config: &RuntimeConfig,
args: &FarmScopedArgs,
@@ -23,6 +32,24 @@ pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<CommandOutpu
Ok(farm_get_output(view))
}
+fn farm_publish_output(view: FarmPublishView) -> CommandOutput {
+ match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::FarmPublish(view)),
+ CommandDisposition::Unconfigured => {
+ CommandOutput::unconfigured(CommandView::FarmPublish(view))
+ }
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::FarmPublish(view))
+ }
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::FarmPublish(view))
+ }
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::FarmPublish(view))
+ }
+ }
+}
+
fn farm_setup_output(view: FarmSetupView) -> CommandOutput {
match view.disposition() {
CommandDisposition::Success => CommandOutput::success(CommandView::FarmSetup(view)),
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -53,6 +53,7 @@ pub fn dispatch(
},
Command::Doctor => doctor::report(config, logging),
Command::Farm(farm_command) => match &farm_command.command {
+ FarmCommand::Publish(args) => farm::publish(config, args),
FarmCommand::Setup(args) => farm::setup(config, args),
FarmCommand::Status(args) => farm::status(config, args),
FarmCommand::Get(args) => farm::get(config, args),
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -87,6 +87,7 @@ pub enum CommandView {
ConfigShow(ConfigShowView),
Doctor(DoctorView),
FarmGet(FarmGetView),
+ FarmPublish(FarmPublishView),
FarmSetup(FarmSetupView),
FarmStatus(FarmStatusView),
Find(FindView),
@@ -710,6 +711,94 @@ impl FarmGetView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct FarmPublishView {
+ pub state: String,
+ pub source: String,
+ pub scope: String,
+ pub path: String,
+ pub config_present: bool,
+ pub dry_run: bool,
+ pub selected_account_id: String,
+ pub selected_account_pubkey: String,
+ pub farm_d_tag: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub requested_signer_session_id: Option<String>,
+ pub profile: FarmPublishComponentView,
+ pub farm: FarmPublishComponentView,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl FarmPublishView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ "partial" | "unavailable" => CommandDisposition::ExternalUnavailable,
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FarmPublishComponentView {
+ pub state: String,
+ pub rpc_method: String,
+ pub event_kind: u32,
+ pub deduplicated: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub job_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub job_status: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub signer_mode: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub signer_session_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_addr: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub idempotency_key: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub job: Option<FarmPublishJobView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event: Option<FarmPublishEventView>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FarmPublishJobView {
+ pub rpc_method: String,
+ pub state: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub job_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub idempotency_key: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub requested_signer_session_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub signer_mode: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub signer_session_id: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FarmPublishEventView {
+ pub kind: u32,
+ pub author: String,
+ pub content: String,
+ pub tags: Vec<Vec<String>>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_addr: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct FarmConfigSummaryView {
pub scope: String,
pub path: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -2,13 +2,14 @@ use std::io::{self, Write};
use crate::domain::runtime::{
AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView,
- FarmConfigSummaryView, FarmGetView, FarmSetupView, FarmStatusView, FindView, JobGetView,
- JobListView, JobWatchView, ListingGetView, ListingMutationView, ListingNewView,
- ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView,
- NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView,
- OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView, OrderWorkflowView,
- RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView, RuntimeLogsView,
- RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView, SyncWatchView,
+ FarmConfigSummaryView, FarmGetView, FarmPublishComponentView, FarmPublishView, FarmSetupView,
+ FarmStatusView, FindView, JobGetView, JobListView, JobWatchView, ListingGetView,
+ ListingMutationView, ListingNewView, ListingValidateView, LocalBackupView, LocalExportView,
+ LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView,
+ OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView,
+ OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView,
+ RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView,
+ SyncActionView, SyncStatusView, SyncWatchView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::{OutputConfig, OutputFormat};
@@ -117,6 +118,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::FarmGet(view) => {
render_farm_get(stdout, view)?;
}
+ CommandView::FarmPublish(view) => {
+ render_farm_publish(stdout, view)?;
+ }
CommandView::FarmSetup(view) => {
render_farm_setup(stdout, view)?;
}
@@ -303,6 +307,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::FarmPublish(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::FarmSetup(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -2267,6 +2275,58 @@ fn render_farm_get(stdout: &mut dyn Write, view: &FarmGetView) -> Result<(), Run
Ok(())
}
+fn render_farm_publish(stdout: &mut dyn Write, view: &FarmPublishView) -> Result<(), RuntimeError> {
+ write_context(stdout, format!("farm publish · {}", view.state).as_str())?;
+ render_owned_pairs(
+ stdout,
+ "farm",
+ &[
+ ("scope", view.scope.clone()),
+ ("path", view.path.clone()),
+ ("account id", view.selected_account_id.clone()),
+ ("account pubkey", view.selected_account_pubkey.clone()),
+ ("farm d_tag", view.farm_d_tag.clone()),
+ ("dry run", yes_no(view.dry_run).to_owned()),
+ ],
+ )?;
+ render_farm_publish_component(stdout, "profile", &view.profile)?;
+ render_farm_publish_component(stdout, "farm record", &view.farm)?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "reason: {reason}")?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_farm_publish_component(
+ stdout: &mut dyn Write,
+ label: &str,
+ component: &FarmPublishComponentView,
+) -> Result<(), RuntimeError> {
+ let mut rows = vec![
+ ("state", component.state.clone()),
+ ("rpc method", component.rpc_method.clone()),
+ ("event kind", component.event_kind.to_string()),
+ ];
+ if let Some(job_id) = &component.job_id {
+ rows.push(("job id", job_id.clone()));
+ }
+ if let Some(job_status) = &component.job_status {
+ rows.push(("job status", job_status.clone()));
+ }
+ if let Some(event_id) = &component.event_id {
+ rows.push(("event id", event_id.clone()));
+ }
+ if let Some(event_addr) = &component.event_addr {
+ rows.push(("event addr", event_addr.clone()));
+ }
+ if let Some(reason) = &component.reason {
+ rows.push(("reason", reason.clone()));
+ }
+ render_owned_pairs(stdout, label, rows.as_slice())
+}
+
fn render_farm_summary(
stdout: &mut dyn Write,
config: &FarmConfigSummaryView,
@@ -2701,6 +2761,7 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::ConfigShow(_) => "config show",
CommandView::Doctor(_) => "doctor",
CommandView::FarmGet(_) => "farm get",
+ CommandView::FarmPublish(_) => "farm publish",
CommandView::FarmSetup(_) => "farm setup",
CommandView::FarmStatus(_) => "farm status",
CommandView::Find(_) => "find",
diff --git a/src/runtime/daemon.rs b/src/runtime/daemon.rs
@@ -1,9 +1,12 @@
use std::time::Duration;
+use radroots_events::farm::RadrootsFarm;
use radroots_events::listing::RadrootsListing;
+use radroots_events::profile::{RadrootsProfile, RadrootsProfileType};
use radroots_events::trade::RadrootsTradeOrder;
use radroots_sdk::{
- RadrootsSdkConfig, RadrootsdAuth, SdkPublishError, SdkRadrootsdListingPublishOptions,
+ RadrootsSdkConfig, RadrootsdAuth, SdkPublishError, SdkRadrootsdFarmPublishOptions,
+ SdkRadrootsdListingPublishOptions, SdkRadrootsdProfilePublishOptions,
SdkRadrootsdSignerAuthority, SdkRadrootsdSignerSessionRef, SdkTransportMode, SignerConfig,
};
use reqwest::blocking::Client;
@@ -145,6 +148,19 @@ pub struct BridgeListingPublishResult {
}
#[derive(Debug, Clone)]
+pub struct BridgeEventPublishResult {
+ pub deduplicated: bool,
+ pub job_id: String,
+ pub idempotency_key: Option<String>,
+ pub status: String,
+ pub signer_mode: String,
+ pub signer_session_id: Option<String>,
+ pub event_kind: Option<u32>,
+ pub event_id: Option<String>,
+ pub event_addr: Option<String>,
+}
+
+#[derive(Debug, Clone)]
pub struct BridgeOrderRequestResult {
pub deduplicated: bool,
pub job_id: String,
@@ -416,6 +432,72 @@ pub fn bridge_listing_publish(
map_listing_publish_receipt(receipt, idempotency_key)
}
+pub fn bridge_profile_publish(
+ config: &RuntimeConfig,
+ profile: &RadrootsProfile,
+ profile_type: Option<RadrootsProfileType>,
+ idempotency_key: Option<&str>,
+ signer_session_id: Option<&str>,
+ signer_authority: Option<&ActorWriteSignerAuthority>,
+) -> Result<BridgeEventPublishResult, DaemonRpcError> {
+ let Some(signer_session_id) = signer_session_id else {
+ return Err(DaemonRpcError::Unconfigured(
+ "profile publish requires a signer session id".to_owned(),
+ ));
+ };
+
+ let sdk = actor_write_sdk_client(config)?;
+ let session = SdkRadrootsdSignerSessionRef::from_session_id(signer_session_id.to_owned());
+ let mut options = SdkRadrootsdProfilePublishOptions::from_signer_session_ref(&session);
+ if let Some(idempotency_key) = idempotency_key {
+ options = options.with_idempotency_key(idempotency_key.to_owned());
+ }
+ if let Some(signer_authority) = signer_authority {
+ options = options.with_signer_authority(sdk_signer_authority(signer_authority));
+ }
+
+ let receipt = block_on_sdk(sdk.profile().publish_profile_via_radrootsd_with_options(
+ profile,
+ profile_type,
+ &options,
+ ))?
+ .map_err(map_sdk_publish_error)?;
+
+ map_event_publish_receipt(receipt, idempotency_key, "profile")
+}
+
+pub fn bridge_farm_publish(
+ config: &RuntimeConfig,
+ farm: &RadrootsFarm,
+ idempotency_key: Option<&str>,
+ signer_session_id: Option<&str>,
+ signer_authority: Option<&ActorWriteSignerAuthority>,
+) -> Result<BridgeEventPublishResult, DaemonRpcError> {
+ let Some(signer_session_id) = signer_session_id else {
+ return Err(DaemonRpcError::Unconfigured(
+ "farm publish requires a signer session id".to_owned(),
+ ));
+ };
+
+ let sdk = actor_write_sdk_client(config)?;
+ let session = SdkRadrootsdSignerSessionRef::from_session_id(signer_session_id.to_owned());
+ let mut options = SdkRadrootsdFarmPublishOptions::from_signer_session_ref(&session);
+ if let Some(idempotency_key) = idempotency_key {
+ options = options.with_idempotency_key(idempotency_key.to_owned());
+ }
+ if let Some(signer_authority) = signer_authority {
+ options = options.with_signer_authority(sdk_signer_authority(signer_authority));
+ }
+
+ let receipt = block_on_sdk(
+ sdk.farm()
+ .publish_farm_via_radrootsd_with_options(farm, &options),
+ )?
+ .map_err(map_sdk_publish_error)?;
+
+ map_event_publish_receipt(receipt, idempotency_key, "farm")
+}
+
pub fn bridge_order_request(
config: &RuntimeConfig,
order: &RadrootsTradeOrder,
@@ -575,6 +657,45 @@ fn map_listing_publish_receipt(
})
}
+fn map_event_publish_receipt(
+ receipt: radroots_sdk::SdkPublishReceipt,
+ idempotency_key: Option<&str>,
+ label: &str,
+) -> Result<BridgeEventPublishResult, DaemonRpcError> {
+ let radroots_sdk::SdkTransportReceipt::Radrootsd(transport_receipt) = receipt.transport_receipt
+ else {
+ return Err(DaemonRpcError::InvalidResponse(format!(
+ "sdk {label} publish returned a non-radrootsd transport receipt"
+ )));
+ };
+ let Some(job_id) = transport_receipt.job_id else {
+ return Err(DaemonRpcError::InvalidResponse(format!(
+ "sdk {label} publish did not return a job id"
+ )));
+ };
+ let Some(status) = transport_receipt.status else {
+ return Err(DaemonRpcError::InvalidResponse(format!(
+ "sdk {label} publish did not return a job status"
+ )));
+ };
+ let Some(signer_mode) = transport_receipt.signer_mode else {
+ return Err(DaemonRpcError::InvalidResponse(format!(
+ "sdk {label} publish did not return a signer mode"
+ )));
+ };
+ Ok(BridgeEventPublishResult {
+ deduplicated: transport_receipt.deduplicated,
+ job_id,
+ idempotency_key: idempotency_key.map(str::to_owned),
+ status,
+ signer_mode,
+ signer_session_id: transport_receipt.signer_session_id,
+ event_kind: receipt.event_kind,
+ event_id: receipt.event_id,
+ event_addr: transport_receipt.event_addr,
+ })
+}
+
fn map_order_request_receipt(
receipt: radroots_sdk::SdkPublishReceipt,
idempotency_key: Option<&str>,
@@ -846,6 +967,8 @@ fn map_rpc_error(method: &str, error: JsonRpcResponseError) -> DaemonRpcError {
fn map_job_command(command: String) -> String {
match command.as_str() {
+ "bridge.profile.publish" => "profile.publish".to_owned(),
+ "bridge.farm.publish" => "farm.publish".to_owned(),
"bridge.listing.publish" => "listing.publish".to_owned(),
"bridge.order.request" => "order.submit".to_owned(),
other => other.to_owned(),
diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs
@@ -2,22 +2,28 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use radroots_events::farm::{RadrootsFarm, RadrootsFarmLocation};
+use radroots_events::kinds::{KIND_FARM, KIND_PROFILE};
use radroots_events::listing::RadrootsListingLocation;
-use radroots_events::profile::RadrootsProfile;
+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 crate::cli::{FarmScopeArg, FarmScopedArgs, FarmSetupArgs};
+use crate::cli::{FarmPublishArgs, FarmScopeArg, FarmScopedArgs, FarmSetupArgs};
use crate::domain::runtime::{
FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView,
- FarmPublicationView, FarmSelectionView, FarmSetupView, FarmStatusView,
+ FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishJobView,
+ FarmPublishView, FarmSelectionView, FarmSetupView, FarmStatusView,
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts::{self, AccountRecordView};
use crate::runtime::config::RuntimeConfig;
+use crate::runtime::daemon::{self, BridgeEventPublishResult, DaemonRpcError};
use crate::runtime::farm_config::{
self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults,
FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION,
};
+use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority};
const FARM_CONFIG_SOURCE: &str = "farm config · local first";
@@ -169,6 +175,718 @@ pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<FarmGetView,
})
}
+pub fn publish(
+ config: &RuntimeConfig,
+ args: &FarmPublishArgs,
+) -> Result<FarmPublishView, RuntimeError> {
+ let scope = scope_from_arg(args.scope);
+ let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?;
+ let path = farm_config::config_path(&config.paths, resolved_scope)?;
+ let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else {
+ return Ok(missing_publish_view(
+ resolved_scope,
+ path.display().to_string(),
+ args,
+ format!("no farm config found at {}", path.display()),
+ ));
+ };
+
+ let Some(account) = configured_account(config, &resolved.document.selection.account)? else {
+ return Ok(missing_publish_view(
+ resolved.scope,
+ resolved.path.display().to_string(),
+ args,
+ format!(
+ "farm config account `{}` is not present in the local account store",
+ resolved.document.selection.account
+ ),
+ ));
+ };
+ let account_pubkey = account.record.public_identity.public_key_hex.clone();
+ let previews = build_publish_previews(&resolved.document, account_pubkey.as_str())?;
+ let profile_idempotency_key = component_idempotency_key(args, "profile")?;
+ let farm_idempotency_key = component_idempotency_key(args, "farm")?;
+
+ if config.output.dry_run {
+ return Ok(base_publish_view(
+ "dry_run",
+ config,
+ args,
+ &resolved,
+ &account_pubkey,
+ preview_component(
+ "bridge.profile.publish",
+ KIND_PROFILE,
+ profile_idempotency_key,
+ args,
+ Some(previews.profile),
+ ),
+ preview_component(
+ "bridge.farm.publish",
+ KIND_FARM,
+ farm_idempotency_key,
+ args,
+ Some(previews.farm),
+ ),
+ Some("dry run requested; daemon farm publish skipped".to_owned()),
+ vec![format!(
+ "radroots farm publish --scope {}",
+ resolved.scope.as_str()
+ )],
+ ));
+ }
+
+ let signer_authority =
+ match resolve_actor_write_authority(config, "farm", account_pubkey.as_str()) {
+ Ok(authority) => authority,
+ Err(error) => {
+ return Ok(binding_error_publish_view(
+ config,
+ args,
+ &resolved,
+ &account_pubkey,
+ previews,
+ profile_idempotency_key,
+ farm_idempotency_key,
+ error,
+ ));
+ }
+ };
+ let profile_signer_session_id = match daemon::resolve_signer_session_id(
+ config,
+ "farm profile",
+ account_pubkey.as_str(),
+ KIND_PROFILE,
+ args.signer_session_id.as_deref(),
+ signer_authority.as_ref(),
+ ) {
+ Ok(session_id) => session_id,
+ Err(error) => {
+ return Ok(daemon_error_publish_view(
+ config,
+ args,
+ &resolved,
+ &account_pubkey,
+ previews,
+ profile_idempotency_key,
+ farm_idempotency_key,
+ error,
+ FarmPublishFailureStage::Profile,
+ ));
+ }
+ };
+ let farm_signer_session_id = match daemon::resolve_signer_session_id(
+ config,
+ "farm",
+ account_pubkey.as_str(),
+ KIND_FARM,
+ Some(profile_signer_session_id.as_str()),
+ signer_authority.as_ref(),
+ ) {
+ Ok(session_id) => session_id,
+ Err(error) => {
+ return Ok(daemon_error_publish_view(
+ config,
+ args,
+ &resolved,
+ &account_pubkey,
+ previews,
+ profile_idempotency_key,
+ farm_idempotency_key,
+ error,
+ FarmPublishFailureStage::Farm,
+ ));
+ }
+ };
+
+ let profile_result = match daemon::bridge_profile_publish(
+ config,
+ &resolved.document.profile,
+ Some(RadrootsProfileType::Farm),
+ profile_idempotency_key.as_deref(),
+ Some(profile_signer_session_id.as_str()),
+ signer_authority.as_ref(),
+ ) {
+ Ok(result) => result_component(
+ "bridge.profile.publish",
+ KIND_PROFILE,
+ result,
+ args,
+ Some(previews.profile.clone()),
+ ),
+ Err(error) => {
+ return Ok(daemon_error_publish_view(
+ config,
+ args,
+ &resolved,
+ &account_pubkey,
+ previews,
+ profile_idempotency_key,
+ farm_idempotency_key,
+ error,
+ FarmPublishFailureStage::Profile,
+ ));
+ }
+ };
+
+ if component_failed(&profile_result) {
+ return Ok(persist_publication_and_view(
+ config,
+ args,
+ resolved,
+ &account_pubkey,
+ profile_result,
+ preview_component(
+ "bridge.farm.publish",
+ KIND_FARM,
+ farm_idempotency_key,
+ args,
+ Some(previews.farm),
+ ),
+ )?);
+ }
+
+ let farm_result = match daemon::bridge_farm_publish(
+ config,
+ &resolved.document.farm,
+ farm_idempotency_key.as_deref(),
+ Some(farm_signer_session_id.as_str()),
+ signer_authority.as_ref(),
+ ) {
+ Ok(result) => result_component(
+ "bridge.farm.publish",
+ KIND_FARM,
+ result,
+ args,
+ Some(previews.farm),
+ ),
+ Err(error) => {
+ let profile_for_error = profile_result.clone();
+ let farm_for_error = daemon_error_component(
+ "bridge.farm.publish",
+ KIND_FARM,
+ args,
+ farm_idempotency_key,
+ error,
+ Some(previews.farm),
+ );
+ return Ok(persist_publication_and_view(
+ config,
+ args,
+ resolved,
+ &account_pubkey,
+ profile_for_error,
+ farm_for_error,
+ )?);
+ }
+ };
+
+ persist_publication_and_view(
+ config,
+ args,
+ resolved,
+ &account_pubkey,
+ profile_result,
+ farm_result,
+ )
+}
+
+#[derive(Debug, Clone)]
+struct FarmPublishPreviews {
+ profile: FarmPublishEventView,
+ farm: FarmPublishEventView,
+}
+
+#[derive(Debug, Clone, Copy)]
+enum FarmPublishFailureStage {
+ Profile,
+ Farm,
+}
+
+fn missing_publish_view(
+ scope: FarmConfigScope,
+ path: String,
+ args: &FarmPublishArgs,
+ reason: String,
+) -> FarmPublishView {
+ FarmPublishView {
+ state: "unconfigured".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ scope: scope.as_str().to_owned(),
+ path,
+ config_present: false,
+ dry_run: false,
+ selected_account_id: String::new(),
+ selected_account_pubkey: String::new(),
+ farm_d_tag: String::new(),
+ requested_signer_session_id: args.signer_session_id.clone(),
+ profile: not_submitted_component("bridge.profile.publish", KIND_PROFILE, args, None, None),
+ farm: not_submitted_component("bridge.farm.publish", KIND_FARM, args, None, None),
+ reason: Some(reason),
+ actions: vec![setup_action(scope), "radroots account whoami".to_owned()],
+ }
+}
+
+fn base_publish_view(
+ state: &str,
+ config: &RuntimeConfig,
+ args: &FarmPublishArgs,
+ resolved: &ResolvedFarmConfig,
+ account_pubkey: &str,
+ profile: FarmPublishComponentView,
+ farm: FarmPublishComponentView,
+ reason: Option<String>,
+ actions: Vec<String>,
+) -> FarmPublishView {
+ FarmPublishView {
+ state: state.to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ scope: resolved.scope.as_str().to_owned(),
+ path: resolved.path.display().to_string(),
+ config_present: true,
+ dry_run: config.output.dry_run,
+ selected_account_id: resolved.document.selection.account.clone(),
+ selected_account_pubkey: account_pubkey.to_owned(),
+ farm_d_tag: resolved.document.selection.farm_d_tag.clone(),
+ requested_signer_session_id: args.signer_session_id.clone(),
+ profile,
+ farm,
+ reason,
+ actions,
+ }
+}
+
+fn build_publish_previews(
+ document: &FarmConfigDocument,
+ account_pubkey: &str,
+) -> Result<FarmPublishPreviews, RuntimeError> {
+ let profile_parts =
+ to_wire_parts_with_profile_type(&document.profile, Some(RadrootsProfileType::Farm))
+ .map_err(|error| RuntimeError::Config(format!("invalid farm profile: {error}")))?;
+ let farm_parts = to_wire_parts_with_kind(&document.farm, KIND_FARM)
+ .map_err(|error| RuntimeError::Config(format!("invalid farm contract: {error}")))?;
+ let farm_addr = format!(
+ "{}:{}:{}",
+ farm_parts.kind, account_pubkey, document.farm.d_tag
+ );
+
+ 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,
+ },
+ 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),
+ },
+ })
+}
+
+fn component_idempotency_key(
+ args: &FarmPublishArgs,
+ component: &str,
+) -> Result<Option<String>, RuntimeError> {
+ args.idempotency_key
+ .as_deref()
+ .map(|value| {
+ required_text(value, "idempotency_key").map(|key| format!("{key}:{component}"))
+ })
+ .transpose()
+}
+
+fn preview_component(
+ rpc_method: &str,
+ event_kind: u32,
+ idempotency_key: Option<String>,
+ args: &FarmPublishArgs,
+ event: Option<FarmPublishEventView>,
+) -> FarmPublishComponentView {
+ FarmPublishComponentView {
+ state: if event.is_some() {
+ "not_submitted".to_owned()
+ } else {
+ "unconfigured".to_owned()
+ },
+ rpc_method: rpc_method.to_owned(),
+ event_kind,
+ deduplicated: false,
+ job_id: None,
+ job_status: None,
+ signer_mode: None,
+ signer_session_id: None,
+ event_id: None,
+ event_addr: event.as_ref().and_then(|event| event.event_addr.clone()),
+ idempotency_key: idempotency_key.clone(),
+ reason: Some("not submitted".to_owned()),
+ job: args.print_job.then(|| FarmPublishJobView {
+ rpc_method: rpc_method.to_owned(),
+ state: "not_submitted".to_owned(),
+ job_id: None,
+ idempotency_key,
+ requested_signer_session_id: args.signer_session_id.clone(),
+ signer_mode: None,
+ signer_session_id: None,
+ }),
+ event: args.print_event.then_some(event).flatten(),
+ }
+}
+
+fn not_submitted_component(
+ rpc_method: &str,
+ event_kind: u32,
+ args: &FarmPublishArgs,
+ idempotency_key: Option<String>,
+ event: Option<FarmPublishEventView>,
+) -> FarmPublishComponentView {
+ preview_component(rpc_method, event_kind, idempotency_key, args, event)
+}
+
+fn result_component(
+ rpc_method: &str,
+ fallback_event_kind: u32,
+ result: BridgeEventPublishResult,
+ args: &FarmPublishArgs,
+ preview: Option<FarmPublishEventView>,
+) -> FarmPublishComponentView {
+ let state = if result.status == "failed" {
+ "failed".to_owned()
+ } else if result.deduplicated {
+ "deduplicated".to_owned()
+ } else {
+ result.status.clone()
+ };
+ let event_kind = result.event_kind.unwrap_or(fallback_event_kind);
+ let event_addr = result
+ .event_addr
+ .clone()
+ .or_else(|| preview.as_ref().and_then(|event| event.event_addr.clone()));
+ let event = args.print_event.then(|| FarmPublishEventView {
+ event_id: result.event_id.clone(),
+ event_addr: event_addr.clone(),
+ ..preview.unwrap_or_else(|| FarmPublishEventView {
+ kind: event_kind,
+ author: String::new(),
+ content: String::new(),
+ tags: Vec::new(),
+ event_id: None,
+ event_addr: None,
+ })
+ });
+ FarmPublishComponentView {
+ state: state.clone(),
+ rpc_method: rpc_method.to_owned(),
+ event_kind,
+ deduplicated: result.deduplicated,
+ job_id: Some(result.job_id.clone()),
+ job_status: Some(result.status.clone()),
+ signer_mode: Some(result.signer_mode.clone()),
+ signer_session_id: result.signer_session_id.clone(),
+ event_id: result.event_id.clone(),
+ event_addr,
+ idempotency_key: result.idempotency_key.clone(),
+ reason: (result.status == "failed")
+ .then(|| "daemon publish job failed before relay delivery completed".to_owned()),
+ job: args.print_job.then(|| FarmPublishJobView {
+ rpc_method: rpc_method.to_owned(),
+ state,
+ job_id: Some(result.job_id),
+ idempotency_key: result.idempotency_key,
+ requested_signer_session_id: args.signer_session_id.clone(),
+ signer_mode: Some(result.signer_mode),
+ signer_session_id: result.signer_session_id,
+ }),
+ event,
+ }
+}
+
+fn daemon_error_component(
+ rpc_method: &str,
+ event_kind: u32,
+ args: &FarmPublishArgs,
+ idempotency_key: Option<String>,
+ error: DaemonRpcError,
+ preview: Option<FarmPublishEventView>,
+) -> FarmPublishComponentView {
+ let (state, reason) = daemon_error_state_reason(error);
+ FarmPublishComponentView {
+ state: state.clone(),
+ rpc_method: rpc_method.to_owned(),
+ event_kind,
+ deduplicated: false,
+ job_id: None,
+ job_status: None,
+ signer_mode: None,
+ signer_session_id: None,
+ event_id: None,
+ event_addr: preview.as_ref().and_then(|event| event.event_addr.clone()),
+ idempotency_key: idempotency_key.clone(),
+ reason: Some(reason),
+ job: args.print_job.then(|| FarmPublishJobView {
+ rpc_method: rpc_method.to_owned(),
+ state,
+ job_id: None,
+ idempotency_key,
+ requested_signer_session_id: args.signer_session_id.clone(),
+ signer_mode: None,
+ signer_session_id: None,
+ }),
+ event: args.print_event.then_some(preview).flatten(),
+ }
+}
+
+fn daemon_error_state_reason(error: DaemonRpcError) -> (String, String) {
+ match error {
+ DaemonRpcError::Unconfigured(reason)
+ | DaemonRpcError::Unauthorized(reason)
+ | DaemonRpcError::MethodUnavailable(reason) => ("unconfigured".to_owned(), reason),
+ DaemonRpcError::External(reason) => ("unavailable".to_owned(), reason),
+ DaemonRpcError::InvalidResponse(reason)
+ | DaemonRpcError::Remote(reason)
+ | DaemonRpcError::UnknownJob(reason) => ("error".to_owned(), reason),
+ }
+}
+
+fn binding_error_publish_view(
+ config: &RuntimeConfig,
+ args: &FarmPublishArgs,
+ resolved: &ResolvedFarmConfig,
+ account_pubkey: &str,
+ previews: FarmPublishPreviews,
+ profile_idempotency_key: Option<String>,
+ farm_idempotency_key: Option<String>,
+ error: ActorWriteBindingError,
+) -> FarmPublishView {
+ let (state, reason, actions) = match error {
+ ActorWriteBindingError::Unconfigured(reason) => (
+ "unconfigured".to_owned(),
+ reason,
+ vec![
+ "radroots --signer myc signer status".to_owned(),
+ "radroots rpc sessions".to_owned(),
+ ],
+ ),
+ ActorWriteBindingError::Unavailable(reason) => (
+ "unavailable".to_owned(),
+ reason,
+ vec![
+ "radroots myc status".to_owned(),
+ "verify RADROOTS_MYC_EXECUTABLE and signer.remote_nip46 binding".to_owned(),
+ ],
+ ),
+ };
+ base_publish_view(
+ state.as_str(),
+ config,
+ args,
+ resolved,
+ account_pubkey,
+ FarmPublishComponentView {
+ state: state.clone(),
+ reason: Some(reason.clone()),
+ ..preview_component(
+ "bridge.profile.publish",
+ KIND_PROFILE,
+ profile_idempotency_key,
+ args,
+ Some(previews.profile),
+ )
+ },
+ FarmPublishComponentView {
+ state: state.clone(),
+ reason: Some(reason.clone()),
+ ..preview_component(
+ "bridge.farm.publish",
+ KIND_FARM,
+ farm_idempotency_key,
+ args,
+ Some(previews.farm),
+ )
+ },
+ Some(reason),
+ actions,
+ )
+}
+
+fn daemon_error_publish_view(
+ config: &RuntimeConfig,
+ args: &FarmPublishArgs,
+ resolved: &ResolvedFarmConfig,
+ account_pubkey: &str,
+ previews: FarmPublishPreviews,
+ profile_idempotency_key: Option<String>,
+ farm_idempotency_key: Option<String>,
+ error: DaemonRpcError,
+ stage: FarmPublishFailureStage,
+) -> FarmPublishView {
+ let (state, reason) = daemon_error_state_reason(error);
+ let profile = match stage {
+ FarmPublishFailureStage::Profile => FarmPublishComponentView {
+ state: state.clone(),
+ reason: Some(reason.clone()),
+ ..preview_component(
+ "bridge.profile.publish",
+ KIND_PROFILE,
+ profile_idempotency_key,
+ args,
+ Some(previews.profile),
+ )
+ },
+ FarmPublishFailureStage::Farm => preview_component(
+ "bridge.profile.publish",
+ KIND_PROFILE,
+ profile_idempotency_key,
+ args,
+ Some(previews.profile),
+ ),
+ };
+ let farm = match stage {
+ FarmPublishFailureStage::Profile => preview_component(
+ "bridge.farm.publish",
+ KIND_FARM,
+ farm_idempotency_key,
+ args,
+ Some(previews.farm),
+ ),
+ FarmPublishFailureStage::Farm => FarmPublishComponentView {
+ state: state.clone(),
+ reason: Some(reason.clone()),
+ ..preview_component(
+ "bridge.farm.publish",
+ KIND_FARM,
+ farm_idempotency_key,
+ args,
+ Some(previews.farm),
+ )
+ },
+ };
+ base_publish_view(
+ state.as_str(),
+ config,
+ args,
+ resolved,
+ account_pubkey,
+ profile,
+ farm,
+ Some(reason),
+ daemon_error_actions(state.as_str()),
+ )
+}
+
+fn persist_publication_and_view(
+ config: &RuntimeConfig,
+ args: &FarmPublishArgs,
+ mut resolved: ResolvedFarmConfig,
+ account_pubkey: &str,
+ profile: FarmPublishComponentView,
+ farm: FarmPublishComponentView,
+) -> Result<FarmPublishView, RuntimeError> {
+ let now = unix_timestamp_now();
+ if component_published(&profile) {
+ if let Some(event_id) = &profile.event_id {
+ resolved.document.publication.profile_event_id = Some(event_id.clone());
+ }
+ resolved.document.publication.profile_published_at = Some(now);
+ }
+ if component_published(&farm) {
+ if let Some(event_id) = &farm.event_id {
+ resolved.document.publication.farm_event_id = Some(event_id.clone());
+ }
+ resolved.document.publication.farm_published_at = Some(now);
+ }
+ if component_published(&profile) || component_published(&farm) {
+ farm_config::write(&config.paths, resolved.scope, &resolved.document)?;
+ }
+
+ let state = publish_view_state(&profile, &farm);
+ let mut actions = Vec::new();
+ if let Some(job_id) = &profile.job_id {
+ actions.push(format!("radroots job get {job_id}"));
+ }
+ if let Some(job_id) = &farm.job_id {
+ actions.push(format!("radroots job get {job_id}"));
+ actions.push(format!("radroots job watch {job_id}"));
+ }
+ if actions.is_empty() {
+ actions.push("radroots rpc status".to_owned());
+ }
+ let reason = (state == "partial" || state == "unavailable" || state == "error")
+ .then(|| "farm publish did not complete for both profile and farm record".to_owned());
+
+ Ok(base_publish_view(
+ state,
+ config,
+ args,
+ &resolved,
+ account_pubkey,
+ profile,
+ farm,
+ reason,
+ actions,
+ ))
+}
+
+fn publish_view_state(
+ profile: &FarmPublishComponentView,
+ farm: &FarmPublishComponentView,
+) -> &'static str {
+ if component_error(profile) || component_error(farm) {
+ return "error";
+ }
+ if component_unconfigured(profile) || component_unconfigured(farm) {
+ return "unconfigured";
+ }
+ if component_failed(profile) || component_failed(farm) {
+ return if component_published(profile) || component_published(farm) {
+ "partial"
+ } else {
+ "unavailable"
+ };
+ }
+ if profile.state == "deduplicated" || farm.state == "deduplicated" {
+ return "deduplicated";
+ }
+ "published"
+}
+
+fn component_published(component: &FarmPublishComponentView) -> bool {
+ matches!(component.state.as_str(), "published" | "deduplicated")
+ || component
+ .job_status
+ .as_deref()
+ .is_some_and(|status| status == "published")
+}
+
+fn component_failed(component: &FarmPublishComponentView) -> bool {
+ matches!(component.state.as_str(), "failed" | "unavailable")
+}
+
+fn component_unconfigured(component: &FarmPublishComponentView) -> bool {
+ component.state == "unconfigured"
+}
+
+fn component_error(component: &FarmPublishComponentView) -> bool {
+ component.state == "error"
+}
+
+fn daemon_error_actions(state: &str) -> Vec<String> {
+ match state {
+ "unconfigured" => vec![
+ "set RADROOTS_RPC_BEARER_TOKEN in .env or your shell".to_owned(),
+ "start radrootsd with bridge ingress enabled".to_owned(),
+ ],
+ "unavailable" => vec!["start radrootsd and verify the rpc url".to_owned()],
+ _ => vec!["inspect the daemon rpc response contract".to_owned()],
+ }
+}
+
fn setup_document(
args: &FarmSetupArgs,
scope: FarmConfigScope,
@@ -413,6 +1131,13 @@ fn non_empty(value: &str) -> Option<String> {
}
}
+fn unix_timestamp_now() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_secs())
+ .unwrap_or_default()
+}
+
fn generate_d_tag() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)