cli

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

commit 371276014e4be8f774661a61bfc4ee37a6c6e59f
parent 0ccd8f54b2faed1e1ba3bff575540051de0b5adb
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 14:54:04 -0700

listing: migrate publish to SDK runtime

Diffstat:
Msrc/cli/global.rs | 1+
Msrc/main.rs | 7+++++++
Msrc/ops/exec/listing.rs | 25+++++++++++++++----------
Msrc/runtime/listing.rs | 479++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/sdk.rs | 34++++++++++++++++++++++++++++------
Mtests/signer_runtime_modes.rs | 36++++++++++++++++++++++++------------
Mtests/target_cli.rs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
7 files changed, 715 insertions(+), 62 deletions(-)

diff --git a/src/cli/global.rs b/src/cli/global.rs @@ -186,6 +186,7 @@ pub struct ListingMutationArgs { pub idempotency_key: Option<String>, pub signer_session_id: Option<String>, pub print_event: bool, + pub offline: bool, } #[derive(Debug, Clone, Default)] diff --git a/src/main.rs b/src/main.rs @@ -404,6 +404,9 @@ fn validate_network_contract( match request.context().network_mode { OperationNetworkMode::Default => Ok(()), OperationNetworkMode::Offline => { + if allows_offline_local_mutation(spec.operation_id) { + return Ok(()); + } if let NetworkRequirement::External { dry_run_requires_network, } = requirement @@ -453,6 +456,10 @@ fn requires_pre_runtime_relay_target(operation_id: &str) -> bool { !is_publish_mode_routed_operation(operation_id) } +fn allows_offline_local_mutation(operation_id: &str) -> bool { + matches!(operation_id, "listing.publish") +} + fn validate_publish_mode_contract( request: &TargetOperationRequest, config: &RuntimeConfig, diff --git a/src/ops/exec/listing.rs b/src/ops/exec/listing.rs @@ -13,8 +13,8 @@ use crate::ops::{ ListingGetRequest, ListingGetResult, ListingListRequest, ListingListResult, ListingPublishRequest, ListingPublishResult, ListingRebindRequest, ListingRebindResult, ListingUpdateRequest, ListingUpdateResult, ListingValidateRequest, ListingValidateResult, - OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, - OperationResult, OperationResultData, OperationService, + OperationAdapterError, OperationNetworkMode, OperationRequest, OperationRequestData, + OperationRequestPayload, OperationResult, OperationResultData, OperationService, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; @@ -224,8 +224,8 @@ impl OperationService<ListingPublishRequest> for ListingOperationService<'_> { } let args = mutation_args(&request)?; let config = mutation_config(self.config, &request); - let view = crate::runtime::listing::publish(&config, &args).map_err(|error| { - OperationAdapterError::runtime_failure(request.operation_id(), error) + let view = crate::runtime::listing::publish_via_sdk(&config, &args).map_err(|error| { + OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) })?; mutation_result::<ListingPublishResult>(request.operation_id(), &view) } @@ -277,6 +277,7 @@ where .or_else(|| string_input(request, "idempotency_key")), signer_session_id: string_input(request, "signer_session_id"), print_event: bool_input(request, "print_event").unwrap_or(false), + offline: matches!(request.context.network_mode, OperationNetworkMode::Offline), }) } @@ -335,12 +336,16 @@ 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()) + matches!( + view.source.as_str(), + "direct Nostr relay publish · local key" | "SDK listing publish · local key" + ) && (view.reason.as_deref().is_some_and(|reason| { + reason.contains("configured relay") + || reason.contains("direct relay connection failed") + || reason.contains("SDK relay publish") + }) || !view.target_relays.is_empty() + || !view.connected_relays.is_empty() + || !view.failed_relays.is_empty()) } fn listing_app_record_export_result<R>( diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -4,12 +4,14 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; use radroots_events::RadrootsNostrEvent; +use radroots_events::contract::RadrootsActorRole; use radroots_events::farm::RadrootsFarmRef; use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId}; use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT}; @@ -23,11 +25,18 @@ use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; use radroots_events_codec::wire::WireEventParts; use radroots_local_events::{LocalEventRecord, LocalRecordFamily, SourceRuntime}; -use radroots_nostr::prelude::{RadrootsNostrEvent as SignedNostrEvent, radroots_event_from_nostr}; +use radroots_nostr::prelude::{ + RadrootsNostrEvent as SignedNostrEvent, RadrootsNostrKeys, radroots_event_from_nostr, +}; use radroots_replica_db::{ReplicaSql, migrations}; use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; +use radroots_sdk::{ + ListingEnqueuePublishRequest, ListingEnqueueReceipt, ListingPreparePublishRequest, + ListingPublishPlan, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, + PushOutboxRelayOutcomeKind, PushOutboxRequest, SdkMutationState, SdkRelayTargetPolicy, +}; use radroots_sql_core::SqliteExecutor; -use radroots_trade::listing::validation::validate_listing_event; +use radroots_trade::listing::{RadrootsListingDraftDocumentV1, validation::validate_listing_event}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -50,6 +59,7 @@ use crate::runtime::local_events::{ list_shared_records_latest, mark_signed_event_acknowledged, mark_signed_event_failed_for_publish_error, shared_local_events_db_path, }; +use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_relay_url_policy}; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime::sync::{ RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness, @@ -67,6 +77,7 @@ const LISTING_SOURCE: &str = "local draft · local first"; const LISTING_READ_SOURCE: &str = "local replica · local first"; const LISTING_APP_RECORD_SOURCE: &str = "shared local events · app"; const RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local key"; +const SDK_LISTING_WRITE_SOURCE: &str = "SDK listing publish · local key"; const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · deferred"; const LISTING_DRAFTS_DIR: &str = "listings/drafts"; const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config"; @@ -240,6 +251,13 @@ struct ListingMutationEventDraft { } #[derive(Debug, Clone)] +struct SdkListingPublishInput { + canonical: CanonicalListingDraft, + actor: RadrootsActorContext, + document: RadrootsListingDraftDocumentV1, +} + +#[derive(Debug, Clone)] struct LoadedListingDraft { file: PathBuf, updated_at_unix: u64, @@ -1735,11 +1753,362 @@ fn refresh_market_listing_if_needed(config: &RuntimeConfig) -> Result<(), Runtim Ok(()) } -pub fn publish( +pub fn publish_via_sdk( config: &RuntimeConfig, args: &ListingMutationArgs, -) -> Result<ListingMutationView, RuntimeError> { - mutate(config, args, ListingMutationOperation::Publish) +) -> Result<ListingMutationView, CliSdkAdapterError> { + let input = sdk_listing_publish_input(config, args)?; + if config.output.dry_run { + validate_local_listing_signer(config, &input.canonical)?; + let session = CliSdkSession::connect_memory(config)?; + let plan = session.sdk().listings().prepare_publish( + ListingPreparePublishRequest::from_document( + input.actor.clone(), + input.document.clone(), + ), + )?; + return Ok(sdk_prepared_publish_view( + config, + args, + &input.canonical, + plan, + )); + } + + let session = CliSdkSession::connect(config)?; + let signer = sdk_listing_signer(config, &input.canonical)?; + let mut request = ListingEnqueuePublishRequest::from_document( + input.actor, + input.document, + SdkRelayTargetPolicy::UseConfiguredRelays, + ); + if let Some(idempotency_key) = args.idempotency_key.as_deref() { + request = request.try_with_idempotency_key(idempotency_key)?; + } + let enqueue_receipt = + session.block_on(session.sdk().listings().enqueue_publish(request, &signer))?; + let push_receipt = if args.offline { + None + } else { + Some( + session.block_on( + session.sdk().sync().push_outbox( + PushOutboxRequest::new() + .with_limit(1) + .with_relay_url_policy(sdk_relay_url_policy(config)), + ), + )?, + ) + }; + Ok(sdk_enqueued_publish_view( + config, + args, + &input.canonical, + enqueue_receipt, + push_receipt, + )) +} + +fn sdk_listing_publish_input( + config: &RuntimeConfig, + args: &ListingMutationArgs, +) -> Result<SdkListingPublishInput, RuntimeError> { + let contents = fs::read_to_string(&args.file)?; + let parsed = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| { + RuntimeError::Config(format!( + "invalid listing draft {}: {error}", + args.file.display() + )) + })?; + let context = mutation_validation_context(config)?; + let canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| { + let issue = match error { + ListingDraftValidationError::MissingSellerAccount(issue) => { + return account::AccountRuntimeFailure::unresolved_with_detail( + format!("{} ({})", issue.message, issue.field), + json!({ + "seller_actor_source": "listing_draft", + "listing_file": args.file.display().to_string(), + "actions": listing_bound_account_recovery_actions(args.file.as_path()), + }), + ) + .into(); + } + ListingDraftValidationError::Issue(issue) => issue, + }; + RuntimeError::Config(format!( + "invalid listing draft {}: {} ({})", + args.file.display(), + issue.message, + issue.field + )) + })?; + ensure_listing_bound_account(config, &canonical, args.file.as_path())?; + let actor = RadrootsActorContext::local_account( + canonical.seller_pubkey.as_str(), + canonical.seller_account_id.clone(), + [RadrootsActorRole::Seller], + ) + .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?; + let document = RadrootsListingDraftDocumentV1::new(canonical.listing.clone()); + Ok(SdkListingPublishInput { + canonical, + actor, + document, + }) +} + +fn sdk_listing_signer( + config: &RuntimeConfig, + canonical: &CanonicalListingDraft, +) -> Result<RadrootsLocalEventSigner, RuntimeError> { + let signing = resolve_listing_signing_identity(config, canonical)?; + let keys: RadrootsNostrKeys = signing.identity.into_keys(); + RadrootsLocalEventSigner::new(keys).map_err(|error| RuntimeError::Config(error.to_string())) +} + +fn sdk_prepared_publish_view( + config: &RuntimeConfig, + args: &ListingMutationArgs, + canonical: &CanonicalListingDraft, + plan: ListingPublishPlan, +) -> ListingMutationView { + let listing_addr = plan.public_listing_addr.as_str().to_owned(); + let event = sdk_plan_event_view(&plan); + ListingMutationView { + state: "dry_run".to_owned(), + operation: ListingMutationOperation::Publish.as_str().to_owned(), + source: SDK_LISTING_WRITE_SOURCE.to_owned(), + file: args.file.display().to_string(), + listing_id: canonical.listing_id.clone(), + listing_addr: listing_addr.clone(), + seller_account_id: canonical.seller_account_id.clone(), + seller_pubkey: canonical.seller_pubkey.clone(), + seller_actor_source: canonical.seller_actor_source.clone(), + event_kind: KIND_LISTING, + dry_run: true, + deduplicated: false, + target_relays: Vec::new(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + job_id: None, + job_status: None, + signer_mode: Some(config.signer.backend.as_str().to_owned()), + event_id: Some(plan.expected_event_id.as_str().to_owned()), + event_addr: Some(listing_addr), + idempotency_key: args.idempotency_key.clone(), + signer_session_id: None, + requested_signer_session_id: args.signer_session_id.clone(), + local_replica: None, + reason: Some("dry run requested; SDK enqueue and relay push skipped".to_owned()), + job: None, + event: args.print_event.then_some(event), + actions: vec![format!("radroots listing publish {}", args.file.display())], + } +} + +fn sdk_enqueued_publish_view( + config: &RuntimeConfig, + args: &ListingMutationArgs, + canonical: &CanonicalListingDraft, + enqueue: ListingEnqueueReceipt, + push: Option<PushOutboxReceipt>, +) -> ListingMutationView { + let push_event = push + .as_ref() + .and_then(|receipt| sdk_push_event_for_listing(&enqueue, receipt)); + let state = sdk_publish_state(args, push_event); + let reason = sdk_publish_reason(args, push_event); + let target_relays = push_event + .map(sdk_push_target_relays) + .unwrap_or_else(|| config.relay.urls.clone()); + let connected_relays = push_event + .map(sdk_push_connected_relays) + .unwrap_or_default(); + let acknowledged_relays = push_event + .map(sdk_push_acknowledged_relays) + .unwrap_or_default(); + let failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); + let event_id = enqueue.signed_event_id.as_str().to_owned(); + let listing_addr = enqueue.public_listing_addr.as_str().to_owned(); + ListingMutationView { + state, + operation: ListingMutationOperation::Publish.as_str().to_owned(), + source: SDK_LISTING_WRITE_SOURCE.to_owned(), + file: args.file.display().to_string(), + listing_id: canonical.listing_id.clone(), + listing_addr: listing_addr.clone(), + seller_account_id: canonical.seller_account_id.clone(), + seller_pubkey: canonical.seller_pubkey.clone(), + seller_actor_source: canonical.seller_actor_source.clone(), + event_kind: KIND_LISTING, + dry_run: false, + deduplicated: matches!(enqueue.state, SdkMutationState::AlreadyQueued), + target_relays, + connected_relays, + acknowledged_relays, + failed_relays, + job_id: None, + job_status: None, + signer_mode: Some(config.signer.backend.as_str().to_owned()), + event_id: Some(event_id), + event_addr: Some(listing_addr), + idempotency_key: args.idempotency_key.clone(), + signer_session_id: None, + requested_signer_session_id: args.signer_session_id.clone(), + local_replica: None, + reason, + job: None, + event: None, + actions: sdk_publish_actions(args, push_event), + } +} + +fn sdk_plan_event_view(plan: &ListingPublishPlan) -> ListingMutationEventView { + ListingMutationEventView { + kind: plan.frozen_draft.kind, + author: plan.frozen_draft.expected_pubkey.clone(), + created_at: Some(plan.frozen_draft.created_at), + content: plan.frozen_draft.content.clone(), + tags: plan.frozen_draft.tags.clone(), + event_id: Some(plan.expected_event_id.as_str().to_owned()), + signature: None, + event_addr: plan.public_listing_addr.as_str().to_owned(), + } +} + +fn sdk_push_event_for_listing<'a>( + enqueue: &ListingEnqueueReceipt, + push: &'a PushOutboxReceipt, +) -> Option<&'a PushOutboxEventReceipt> { + push.events + .iter() + .find(|event| event.event_id == enqueue.signed_event_id) +} + +fn sdk_publish_state( + args: &ListingMutationArgs, + push_event: Option<&PushOutboxEventReceipt>, +) -> String { + match push_event.map(|event| event.final_state) { + Some(PushOutboxEventState::Published) => "published", + Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => { + "unavailable" + } + Some(_) | None if args.offline => "queued", + Some(_) | None => "queued", + } + .to_owned() +} + +fn sdk_publish_reason( + args: &ListingMutationArgs, + push_event: Option<&PushOutboxEventReceipt>, +) -> Option<String> { + match push_event.map(|event| event.final_state) { + Some(PushOutboxEventState::Published) => None, + Some(PushOutboxEventState::PublishRetryable) => Some( + "SDK relay publish did not reach accepted quorum; outbox event remains retryable" + .to_owned(), + ), + Some(PushOutboxEventState::FailedTerminal) => { + Some("SDK relay publish failed terminally".to_owned()) + } + Some(state) => Some(format!("SDK relay push left event in state `{state:?}`")), + None if args.offline => Some( + "listing publish queued in SDK outbox; relay push skipped for offline mode".to_owned(), + ), + None => Some( + "listing publish queued in SDK outbox; no ready SDK outbox event was pushed".to_owned(), + ), + } +} + +fn sdk_publish_actions( + args: &ListingMutationArgs, + push_event: Option<&PushOutboxEventReceipt>, +) -> Vec<String> { + if args.offline + || !matches!( + push_event.map(|event| event.final_state), + Some(PushOutboxEventState::Published) + ) + { + return vec!["radroots sync push".to_owned()]; + } + Vec::new() +} + +fn sdk_push_target_relays(event: &PushOutboxEventReceipt) -> Vec<String> { + event + .relays + .iter() + .map(|relay| relay.relay_url.clone()) + .collect() +} + +fn sdk_push_connected_relays(event: &PushOutboxEventReceipt) -> Vec<String> { + event + .relays + .iter() + .filter(|relay| relay.attempted) + .map(|relay| relay.relay_url.clone()) + .collect() +} + +fn sdk_push_acknowledged_relays(event: &PushOutboxEventReceipt) -> Vec<String> { + event + .relays + .iter() + .filter(|relay| { + matches!( + relay.outcome_kind, + PushOutboxRelayOutcomeKind::Accepted + | PushOutboxRelayOutcomeKind::DuplicateAccepted + ) + }) + .map(|relay| relay.relay_url.clone()) + .collect() +} + +fn sdk_push_failed_relays(event: &PushOutboxEventReceipt) -> Vec<RelayFailureView> { + event + .relays + .iter() + .filter(|relay| { + !matches!( + relay.outcome_kind, + PushOutboxRelayOutcomeKind::Accepted + | PushOutboxRelayOutcomeKind::DuplicateAccepted + ) + }) + .map(|relay| RelayFailureView { + relay: relay.relay_url.clone(), + reason: relay + .message + .clone() + .unwrap_or_else(|| sdk_relay_outcome_kind(relay.outcome_kind).to_owned()), + }) + .collect() +} + +fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str { + match kind { + PushOutboxRelayOutcomeKind::Accepted => "accepted", + PushOutboxRelayOutcomeKind::DuplicateAccepted => "duplicate_accepted", + PushOutboxRelayOutcomeKind::Blocked => "blocked", + PushOutboxRelayOutcomeKind::RateLimited => "rate_limited", + PushOutboxRelayOutcomeKind::Invalid => "invalid", + PushOutboxRelayOutcomeKind::PowRequired => "pow_required", + PushOutboxRelayOutcomeKind::Restricted => "restricted", + PushOutboxRelayOutcomeKind::AuthRequired => "auth_required", + PushOutboxRelayOutcomeKind::Error => "error", + PushOutboxRelayOutcomeKind::Timeout => "timeout", + PushOutboxRelayOutcomeKind::ConnectionFailed => "connection_failed", + PushOutboxRelayOutcomeKind::Unknown => "unknown", + _ => "unknown", + } } pub fn update( @@ -3440,13 +3809,21 @@ fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { mod tests { use super::{ DRAFT_KIND, ListingDraftDocument, direct_relay_error_view_parts, encode_base64url_no_pad, - generate_d_tag, ingest_listing_event_into_local_replica, + generate_d_tag, ingest_listing_event_into_local_replica, sdk_publish_actions, + sdk_publish_reason, sdk_publish_state, sdk_push_acknowledged_relays, + sdk_push_failed_relays, }; + use crate::cli::global::ListingMutationArgs; use crate::runtime::direct_relay::{DirectRelayFailure, DirectRelayPublishError}; + use radroots_events::ids::RadrootsEventId; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::wire::WireEventParts; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{RadrootsNostrTimestamp, radroots_nostr_build_event}; + use radroots_sdk::{ + PushOutboxEventReceipt, PushOutboxEventState, PushOutboxRelayOutcomeKind, + PushOutboxRelayReceipt, + }; #[test] fn generated_listing_d_tag_is_valid_base64url() { @@ -3483,6 +3860,49 @@ mod tests { } #[test] + fn sdk_push_receipt_helpers_map_published_and_auth_required_states() { + let accepted = sdk_push_event( + PushOutboxEventState::Published, + PushOutboxRelayOutcomeKind::Accepted, + Some("accepted".to_owned()), + ); + let args = listing_mutation_args(false); + + assert_eq!(sdk_publish_state(&args, Some(&accepted)), "published"); + assert!(sdk_publish_reason(&args, Some(&accepted)).is_none()); + assert!(sdk_publish_actions(&args, Some(&accepted)).is_empty()); + assert_eq!( + sdk_push_acknowledged_relays(&accepted), + vec!["ws://127.0.0.1:19000".to_owned()] + ); + assert!(sdk_push_failed_relays(&accepted).is_empty()); + + let auth_required = sdk_push_event( + PushOutboxEventState::PublishRetryable, + PushOutboxRelayOutcomeKind::AuthRequired, + Some("auth required".to_owned()), + ); + let failed = sdk_push_failed_relays(&auth_required); + + assert_eq!( + sdk_publish_state(&args, Some(&auth_required)), + "unavailable" + ); + assert!( + sdk_publish_reason(&args, Some(&auth_required)) + .expect("retry reason") + .contains("accepted quorum") + ); + assert_eq!(failed.len(), 1); + assert_eq!(failed[0].relay, "ws://127.0.0.1:19000"); + assert_eq!(failed[0].reason, "auth required"); + assert_eq!( + sdk_publish_actions(&args, Some(&auth_required)), + vec!["radroots sync push".to_owned()] + ); + } + + #[test] fn local_replica_ingest_reports_missing_store() { let temp = tempfile::tempdir().expect("tempdir"); let event = signed_test_listing_event(WireEventParts { @@ -3732,6 +4152,53 @@ mod tests { ); } + fn sdk_push_event( + final_state: PushOutboxEventState, + outcome_kind: PushOutboxRelayOutcomeKind, + message: Option<String>, + ) -> PushOutboxEventReceipt { + PushOutboxEventReceipt { + event_id: RadrootsEventId::parse("e".repeat(64)).expect("event id"), + outbox_event_id: 7, + final_state, + attempted_count: 1, + accepted_count: usize::from(matches!( + outcome_kind, + PushOutboxRelayOutcomeKind::Accepted + | PushOutboxRelayOutcomeKind::DuplicateAccepted + )), + retryable_count: usize::from(matches!( + outcome_kind, + PushOutboxRelayOutcomeKind::AuthRequired + | PushOutboxRelayOutcomeKind::Timeout + | PushOutboxRelayOutcomeKind::ConnectionFailed + )), + terminal_count: 0, + quorum: 1, + quorum_met: matches!( + outcome_kind, + PushOutboxRelayOutcomeKind::Accepted + | PushOutboxRelayOutcomeKind::DuplicateAccepted + ), + relays: vec![PushOutboxRelayReceipt { + relay_url: "ws://127.0.0.1:19000".to_owned(), + outcome_kind, + attempted: true, + message, + }], + } + } + + fn listing_mutation_args(offline: bool) -> ListingMutationArgs { + ListingMutationArgs { + file: "listing.toml".into(), + idempotency_key: None, + signer_session_id: None, + print_event: false, + offline, + } + } + fn signed_test_listing_event( parts: WireEventParts, ) -> radroots_nostr::prelude::RadrootsNostrEvent { diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -61,12 +61,7 @@ pub struct CliSdkSession { impl CliSdkSession { pub fn connect(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> { let sdk_config = CliSdkConfig::from_runtime_config(config); - let runtime = TokioRuntimeBuilder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| { - RuntimeError::Config(format!("failed to initialize SDK async runtime: {error}")) - })?; + let runtime = sdk_runtime()?; let sdk = runtime.block_on(sdk_config.builder().build())?; Ok(Self { runtime, @@ -75,6 +70,17 @@ impl CliSdkSession { }) } + pub fn connect_memory(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> { + let sdk_config = CliSdkConfig::from_runtime_config(config); + let runtime = sdk_runtime()?; + let sdk = runtime.block_on(memory_builder(&sdk_config).build())?; + Ok(Self { + runtime, + sdk, + config: sdk_config, + }) + } + pub fn sdk(&self) -> &RadrootsSdk { &self.sdk } @@ -134,6 +140,22 @@ pub fn sdk_storage_root(config: &RuntimeConfig) -> PathBuf { config.local.root.join(SDK_STORAGE_DIR_NAME) } +fn sdk_runtime() -> Result<Runtime, RuntimeError> { + TokioRuntimeBuilder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| { + RuntimeError::Config(format!("failed to initialize SDK async runtime: {error}")) + }) +} + +fn memory_builder(config: &CliSdkConfig) -> RadrootsSdkBuilder { + config.relay_urls.iter().fold( + RadrootsSdk::builder().relay_url_policy(config.relay_url_policy), + |builder, relay_url| builder.relay_url(relay_url.clone()), + ) +} + pub fn sdk_relay_url_policy(config: &RuntimeConfig) -> SdkRelayUrlPolicy { if config .relay diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -955,12 +955,9 @@ fn local_listing_publish_fails_without_configured_relay() { assert!(!output.status.success()); assert_eq!(value["operation_id"], "listing.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"], - "requires at least one configured relay", - ); + assert_eq!(value["errors"][0]["code"], "empty_target_relays"); + assert_eq!(value["errors"][0]["detail"]["class"], "configuration"); + assert_contains(&value["errors"][0]["message"], "sdk empty target relays"); assert_no_removed_command_reference(&value, &["listing", "publish"]); assert_no_daemon_runtime_reference(&value, &["listing", "publish"]); } @@ -985,7 +982,17 @@ fn local_listing_publish_dry_run_does_not_sign_matching_listing() { assert_eq!(value["dry_run"], true); assert_eq!(value["result"]["state"], "dry_run"); assert_eq!(value["result"]["dry_run"], true); - assert_eq!(value["result"]["event_id"], serde_json::Value::Null); + assert_eq!( + value["result"]["event_id"] + .as_str() + .expect("dry-run event id") + .len(), + 64 + ); + assert!( + !sandbox.root().join("data/apps/cli/replica/sdk").exists(), + "dry-run must not materialize durable SDK storage" + ); assert_no_removed_command_reference(&value, &["listing", "publish", "--dry-run"]); assert_no_daemon_runtime_reference(&value, &["listing", "publish", "--dry-run"]); } @@ -2022,11 +2029,16 @@ fn local_seller_publish_commands_attempt_configured_direct_relay() { listing_file_arg.as_ref(), ]); assert!(!publish_output.status.success()); - assert_direct_relay_connection_failure( - &publish_value, - "listing.publish", - &["listing", "publish"], + assert_eq!(publish_value["operation_id"], "listing.publish"); + assert_eq!(publish_value["result"], serde_json::Value::Null); + assert_eq!(publish_value["errors"][0]["code"], "network_unavailable"); + assert_eq!(publish_value["errors"][0]["detail"]["class"], "network"); + assert_contains( + &publish_value["errors"][0]["message"], + "SDK relay publish did not reach accepted quorum", ); + assert_no_removed_command_reference(&publish_value, &["listing", "publish"]); + assert_no_daemon_runtime_reference(&publish_value, &["listing", "publish"]); assert_eq!( publish_value["errors"][0]["detail"]["target_relays"][0], relay @@ -2036,7 +2048,7 @@ fn local_seller_publish_commands_attempt_configured_direct_relay() { .as_array() .expect("connected relays") .len(), - 0 + 1 ); assert_eq!( publish_value["errors"][0]["detail"]["failed_relays"] diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -3054,6 +3054,148 @@ fn offline_allows_supported_external_dry_run() { } #[test] +fn offline_listing_publish_enqueues_sdk_outbox_without_direct_relay_push() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Offline Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let farm_d_tag = farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"); + let listing_file = create_listing_draft(&sandbox, "offline-sdk-enqueue"); + make_listing_publishable(&listing_file, farm_d_tag); + let relay = "ws://127.0.0.1:9"; + let local_event_records_before_publish = sandbox.local_event_records().len(); + + let publish = sandbox.json_success(&[ + "--format", + "json", + "--offline", + "--relay", + relay, + "--approval-token", + "approve", + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]); + + assert_eq!(publish["operation_id"], "listing.publish"); + assert_eq!(publish["result"]["state"], "queued"); + assert_eq!( + publish["result"]["source"], + "SDK listing publish · local key" + ); + assert_eq!(publish["result"]["target_relays"][0], relay); + assert_eq!(publish["result"]["actions"][0], "radroots sync push"); + assert_eq!( + publish["result"]["event_id"] + .as_str() + .expect("sdk event id") + .len(), + 64 + ); + assert!( + sandbox + .root() + .join("data/apps/cli/replica/sdk/outbox.sqlite") + .exists() + ); + assert_eq!( + sandbox.local_event_records().len(), + local_event_records_before_publish + ); +} + +#[test] +fn listing_publish_idempotency_conflict_maps_sdk_partial_mutation_recovery() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Conflict Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let farm_d_tag = farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"); + let listing_file = create_listing_draft(&sandbox, "idem-conflict"); + make_listing_publishable(&listing_file, farm_d_tag); + let relay = "ws://127.0.0.1:9"; + let idempotency_key = "listing-idem-conflict"; + + sandbox.json_success(&[ + "--format", + "json", + "--offline", + "--relay", + relay, + "--approval-token", + "approve", + "--idempotency-key", + idempotency_key, + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]); + let raw = fs::read_to_string(&listing_file).expect("listing draft"); + fs::write( + &listing_file, + raw.replace("title = \"Eggs\"", "title = \"Conflict Eggs\""), + ) + .expect("rewrite listing draft"); + + let (output, conflict) = sandbox.json_output(&[ + "--format", + "json", + "--offline", + "--relay", + relay, + "--approval-token", + "approve", + "--idempotency-key", + idempotency_key, + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]); + + assert!(!output.status.success()); + assert_eq!(conflict["operation_id"], "listing.publish"); + assert_eq!(conflict["errors"][0]["code"], "partial_local_mutation"); + assert_eq!(conflict["errors"][0]["detail"]["class"], "local_mutation"); + assert_eq!( + conflict["errors"][0]["detail"]["detail"]["failure"], + "outbox_idempotency_conflict" + ); + assert_eq!( + conflict["errors"][0]["detail"]["actions"][0], + "radroots listing publish" + ); +} + +#[test] fn offline_rejects_order_decision_dry_run() { for (operation_id, args) in [ ( @@ -5271,7 +5413,7 @@ fn farm_publish_writes_acknowledged_signed_outbox_records() { } #[test] -fn listing_publish_failure_writes_failed_signed_outbox_record() { +fn listing_publish_failure_uses_sdk_outbox_without_legacy_local_event_record() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); let farm = sandbox.json_success(&[ @@ -5293,14 +5435,14 @@ fn listing_publish_failure_writes_failed_signed_outbox_record() { .expect("farm d tag"); let listing_file = create_listing_draft(&sandbox, "failed-outbox-eggs"); make_listing_publishable(&listing_file, farm_d_tag); - let relay = RelayPublishServer::with_publish_outcomes(vec![(false, "rejected by test relay")]); - let relay_url = relay.endpoint().to_owned(); + let relay_url = "ws://127.0.0.1:9"; + let local_event_records_before_publish = sandbox.local_event_records().len(); let (output, publish) = sandbox.json_output(&[ "--format", "json", "--relay", - relay_url.as_str(), + relay_url, "--approval-token", "approve", "listing", @@ -5311,36 +5453,33 @@ fn listing_publish_failure_writes_failed_signed_outbox_record() { assert!(!output.status.success()); assert_eq!(publish["operation_id"], "listing.publish"); assert_eq!(publish["errors"][0]["code"], "network_unavailable"); - let requests = relay.take_requests(1); - assert_eq!(requests.len(), 1); - - let records = sandbox.local_event_records(); - let signed_records = records - .iter() - .filter(|record| record.family == LocalRecordFamily::SignedEvent) - .collect::<Vec<_>>(); - assert_eq!(signed_records.len(), 1); - let record = signed_records[0]; - assert_eq!(record.status, LocalRecordStatus::Failed); - assert_eq!(record.outbox_status, PublishOutboxStatus::Failed); - assert_eq!(record.source_runtime, SourceRuntime::Cli); - assert_eq!(record.farm_id.as_deref(), Some(farm_d_tag)); - assert_eq!(record.event_kind, Some(30402)); assert_eq!( - record.relay_delivery_json.as_ref().unwrap()["state"], - "failed" + publish["errors"][0]["detail"]["source"], + "SDK listing publish · local key" + ); + assert_eq!(publish["errors"][0]["detail"]["state"], "unavailable"); + assert_eq!( + publish["errors"][0]["detail"]["target_relays"][0], + relay_url ); assert_eq!( - record.relay_delivery_json.as_ref().unwrap()["failed_relays"][0]["relay_url"], + publish["errors"][0]["detail"]["failed_relays"][0]["relay"], relay_url ); assert_eq!( - record.relay_delivery_json.as_ref().unwrap()["failed_relays"][0]["error"], - "rejected by test relay" + publish["errors"][0]["detail"]["actions"][0], + "radroots sync push" ); assert_eq!( - record.raw_event_json.as_ref().unwrap()["id"], - record.event_id.as_deref().expect("event id") + publish["errors"][0]["detail"]["event_id"] + .as_str() + .expect("sdk event id") + .len(), + 64 + ); + assert_eq!( + sandbox.local_event_records().len(), + local_event_records_before_publish ); } @@ -7778,11 +7917,11 @@ fn seller_target_flow_acceptance_uses_target_operations() { assert_eq!(unavailable_publish["operation_id"], "listing.publish"); assert_eq!( unavailable_publish["errors"][0]["code"], - "network_unavailable" + "empty_target_relays" ); assert_eq!( unavailable_publish["errors"][0]["detail"]["class"], - "network" + "configuration" ); assert_no_removed_command_reference(&unavailable_publish, &["listing", "publish"]); assert_no_daemon_runtime_reference(&unavailable_publish, &["listing", "publish"]);