cli

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

commit 6e327289081decf9e6612556a78b6791fb18e7fe
parent ef12d1ef04a7c9f3d4815ab53a9abc6b35ad7c4d
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 07:10:30 +0000

transport: align cli hybrid sync status

Diffstat:
Msrc/cli/transport.rs | 2++
Msrc/main.rs | 13+++++++++----
Msrc/ops/exec/core.rs | 6+++---
Msrc/ops/exec/farm.rs | 5++++-
Msrc/ops/exec/market.rs | 4++--
Msrc/out/terminal/renderers/sync.rs | 7++++++-
Msrc/runtime/config.rs | 41++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/farm.rs | 6++++--
Msrc/runtime/provider.rs | 6+++---
Msrc/runtime/sdk.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/runtime/sync.rs | 327++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/runtime/transport.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/view/runtime.rs | 49++++++++++++++++++++++++++++++++++++++++++-------
Mtests/target_cli.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
14 files changed, 503 insertions(+), 162 deletions(-)

diff --git a/src/cli/transport.rs b/src/cli/transport.rs @@ -48,6 +48,7 @@ pub enum TransportProfileKindArg { LocalOnly, Nostr, ReticulumPreview, + Hybrid, Proxy, } @@ -57,6 +58,7 @@ impl TransportProfileKindArg { Self::LocalOnly => "local_only", Self::Nostr => "nostr", Self::ReticulumPreview => "reticulum_preview", + Self::Hybrid => "hybrid", Self::Proxy => "proxy", } } diff --git a/src/main.rs b/src/main.rs @@ -515,8 +515,10 @@ fn validate_transport_profile_contract( ), }); } - if matches!(config.transport.profile, TransportProfileKind::Nostr) - && config.transport.nostr_relay_urls.is_empty() + if matches!( + config.transport.profile, + TransportProfileKind::Nostr | TransportProfileKind::Hybrid + ) && config.transport.nostr_relay_urls.is_empty() { return Err(OperationAdapterError::NetworkUnavailable { operation_id: spec.operation_id.to_owned(), @@ -553,9 +555,12 @@ fn is_transport_profile_routed_operation(operation_id: &str) -> bool { fn transport_profile_delivery_unavailable_reason(config: &RuntimeConfig) -> Option<String> { match config.transport.profile { - TransportProfileKind::Nostr => { + TransportProfileKind::Nostr | TransportProfileKind::Hybrid => { config.transport.nostr_relay_urls.is_empty().then(|| { - "active Nostr transport profile has no configured relay targets".to_owned() + format!( + "active {} transport profile has no configured Nostr relay targets", + config.transport.profile.as_str() + ) }) } TransportProfileKind::Proxy => match runtime::transport::proxy_token_ready(config) { diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs @@ -804,7 +804,7 @@ fn publish_runtime_view( }; match config.transport.profile { - TransportProfileKind::Nostr => { + TransportProfileKind::Nostr | TransportProfileKind::Hybrid => { let (state, executable, reason) = nostr_publish_readiness(config, relay_ready, signed_write_required, account); PublishRuntimeView { @@ -817,7 +817,7 @@ fn publish_runtime_view( signed_write_required, relay, provider: PublishProviderRuntimeView { - provider_runtime_id: "nostr".to_owned(), + provider_runtime_id: config.transport.profile.as_str().to_owned(), state: state.to_owned(), source: config.transport.source.as_str().to_owned(), reason, @@ -1101,7 +1101,7 @@ fn publish_recovery_actions( let mut actions = Vec::new(); match config.transport.profile { - TransportProfileKind::Nostr => { + TransportProfileKind::Nostr | TransportProfileKind::Hybrid => { if config.transport.nostr_relay_urls.is_empty() { push_unique( &mut actions, diff --git a/src/ops/exec/farm.rs b/src/ops/exec/farm.rs @@ -232,7 +232,10 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { request.operation_id(), )); } - if matches!(self.config.transport.profile, TransportProfileKind::Nostr) { + if matches!( + self.config.transport.profile, + TransportProfileKind::Nostr | TransportProfileKind::Hybrid + ) { require_relay_target(&request, self.config)?; } diff --git a/src/ops/exec/market.rs b/src/ops/exec/market.rs @@ -81,7 +81,7 @@ fn market_refresh_view(mut view: SyncActionView) -> SyncActionView { if view.replica_db == "missing" { actions.push("radroots store init".to_owned()); } - if view.relay_count == 0 { + if view.configured_transport_target_count == 0 { actions.push("radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com".to_owned()); } if actions.is_empty() { @@ -327,7 +327,7 @@ mod tests { assert_eq!(envelope.operation_id, "market.refresh"); assert_eq!(envelope.result["state"], "ready"); assert_eq!( - envelope.result["target_relays"][0], + envelope.result["target_transport_endpoints"][0], "wss://relay.example.com" ); assert_eq!(envelope.result["fetched_count"], 0); diff --git a/src/out/terminal/renderers/sync.rs b/src/out/terminal/renderers/sync.rs @@ -32,7 +32,12 @@ impl TerminalOperationRenderer for SyncRenderer { result, &["freshness", "display"], ); - common::push_count_field(&mut document, "Relays", result, &["relay_count"]); + common::push_count_field( + &mut document, + "Transport targets", + result, + &["configured_transport_target_count"], + ); common::push_count_field(&mut document, "Fetched", result, &["fetched_count"]); common::push_count_field(&mut document, "Ingested", result, &["ingested_count"]); common::push_count_field(&mut document, "Skipped", result, &["skipped_count"]); diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -186,6 +186,7 @@ pub enum TransportProfileKind { LocalOnly, Nostr, ReticulumPreview, + Hybrid, Proxy, } @@ -195,6 +196,7 @@ impl TransportProfileKind { Self::LocalOnly => "local_only", Self::Nostr => "nostr", Self::ReticulumPreview => "reticulum_preview", + Self::Hybrid => "hybrid", Self::Proxy => "proxy", } } @@ -204,6 +206,7 @@ impl TransportProfileKind { Self::LocalOnly => "local", Self::Nostr => "nostr", Self::ReticulumPreview => "reticulum", + Self::Hybrid => "hybrid", Self::Proxy => "proxy", } } @@ -1885,9 +1888,10 @@ fn parse_transport_profile_kind( "local_only" => Ok(TransportProfileKind::LocalOnly), "nostr" => Ok(TransportProfileKind::Nostr), "reticulum_preview" => Ok(TransportProfileKind::ReticulumPreview), + "hybrid" => Ok(TransportProfileKind::Hybrid), "proxy" => Ok(TransportProfileKind::Proxy), other => Err(RuntimeError::Config(format!( - "{source} must be `local_only`, `nostr`, `reticulum_preview`, or `proxy`, got `{other}`" + "{source} must be `local_only`, `nostr`, `reticulum_preview`, `hybrid`, or `proxy`, got `{other}`" ))), } } @@ -2857,6 +2861,41 @@ RADROOTS_CLI_LOGGING_STDOUT=true } #[test] + fn hybrid_transport_config_uses_nostr_targets_and_reticulum_preview_behavior() { + let temp = tempdir().expect("tempdir"); + let workspace_root = temp.path().join("workspace"); + let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); + let app_config_dir = repo_local_root.join("config/apps/cli"); + let user_home = temp.path().join("home"); + fs::create_dir_all(&repo_local_root).expect("workspace config dir"); + fs::create_dir_all(&app_config_dir).expect("app config dir"); + fs::write( + app_config_dir.join("config.toml"), + "[transport]\nprofile = \"hybrid\"\n\n[transport.nostr]\nrelay_urls = [\"wss://relay.user\", \"wss://relay.backup\"]\n\n[transport.reticulum_preview]\nbehavior = \"defer_delivery_plans\"\n", + ) + .expect("write user config"); + + let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); + let resolved = + RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) + .expect("resolve hybrid transport profile"); + + assert_eq!(resolved.transport.profile, TransportProfileKind::Hybrid); + assert_eq!(resolved.transport.source, TransportConfigSource::UserConfig); + assert_eq!( + resolved.transport.nostr_relay_urls, + vec![ + "wss://relay.user".to_owned(), + "wss://relay.backup".to_owned() + ] + ); + assert_eq!( + resolved.transport.reticulum_preview_behavior, + super::ReticulumPreviewBehavior::DeferDeliveryPlans + ); + } + + #[test] fn transport_profile_precedence_tracks_source() { let temp = tempdir().expect("tempdir"); let workspace_root = temp.path().join("workspace"); diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -621,8 +621,10 @@ fn relay_farm_publish_readiness( config: &RuntimeConfig, account: &AccountRecordView, ) -> FarmPublishReadiness { - if matches!(config.transport.profile, TransportProfileKind::Nostr) - && config.transport.nostr_relay_urls.is_empty() + if matches!( + config.transport.profile, + TransportProfileKind::Nostr | TransportProfileKind::Hybrid + ) && config.transport.nostr_relay_urls.is_empty() { return FarmPublishReadiness { state: "unconfigured", diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs @@ -97,10 +97,10 @@ pub fn resolve_write_plane_provider( publish: &PublishRuntimeView, ) -> WritePlaneProviderView { let (provider_runtime_id, binding_model, detail) = match config.transport.profile { - TransportProfileKind::Nostr => ( - "nostr", + TransportProfileKind::Nostr | TransportProfileKind::Hybrid => ( + config.transport.profile.as_str(), "nostr_transport", - "Nostr transport profile is selected; readiness is reported under publish", + "configured transport profile publishes through Nostr; readiness is reported under publish", ), TransportProfileKind::Proxy => ( "proxy", diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -16,10 +16,10 @@ use radroots_nostr_connect::prelude::{ RadrootsNostrConnectClientTarget, RadrootsNostrConnectError, RadrootsNostrConnectUri, }; use radroots_sdk::{ - NostrProfile, NostrRelayUrlPolicy, ProxyProfile, RadrootsClient, RadrootsClientBuilder, - RadrootsSdkError, RadrootsSdkLocalKeySigner, RadrootsSdkMycNip46RequestPolicy, - RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, RadrootsSdkNip46TransportFuture, - RadrootsSdkSignerProvider, RadrootsSdkStorageConfig, + HybridProfile, NostrProfile, NostrRelayUrlPolicy, ProxyProfile, RadrootsClient, + RadrootsClientBuilder, RadrootsSdkError, RadrootsSdkLocalKeySigner, + RadrootsSdkMycNip46RequestPolicy, RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, + RadrootsSdkNip46TransportFuture, RadrootsSdkSignerProvider, RadrootsSdkStorageConfig, ReticulumPreviewBehavior as SdkReticulumPreviewBehavior, ReticulumPreviewProfile, TargetPolicy, TransportProfile, }; @@ -632,18 +632,19 @@ fn sdk_transport_profile(config: &RuntimeConfig) -> Result<TransportProfile, Run .map_err(|error| RuntimeError::Config(error.to_string()))?; Ok(TransportProfile::nostr(profile)) } - TransportProfileKind::ReticulumPreview => { - let behavior = match config.transport.reticulum_preview_behavior { - ReticulumPreviewBehavior::RejectDeliveryAttempts => { - SdkReticulumPreviewBehavior::RejectDeliveryAttempts - } - ReticulumPreviewBehavior::DeferDeliveryPlans => { - SdkReticulumPreviewBehavior::DeferDeliveryPlans - } - }; - Ok(TransportProfile::reticulum_preview( - ReticulumPreviewProfile::preview_unavailable().with_behavior(behavior), - )) + TransportProfileKind::ReticulumPreview => Ok(TransportProfile::reticulum_preview( + sdk_reticulum_preview_profile(config), + )), + TransportProfileKind::Hybrid => { + let nostr = NostrProfile::new( + config.transport.nostr_relay_urls.iter().map(String::as_str), + sdk_nostr_relay_url_policy(config), + ) + .map_err(|error| RuntimeError::Config(error.to_string()))?; + Ok(TransportProfile::hybrid(HybridProfile::new( + nostr, + sdk_reticulum_preview_profile(config), + ))) } TransportProfileKind::Proxy => { let profile = ProxyProfile::new(config.transport.proxy.url.clone()) @@ -653,6 +654,18 @@ fn sdk_transport_profile(config: &RuntimeConfig) -> Result<TransportProfile, Run } } +fn sdk_reticulum_preview_profile(config: &RuntimeConfig) -> ReticulumPreviewProfile { + let behavior = match config.transport.reticulum_preview_behavior { + ReticulumPreviewBehavior::RejectDeliveryAttempts => { + SdkReticulumPreviewBehavior::RejectDeliveryAttempts + } + ReticulumPreviewBehavior::DeferDeliveryPlans => { + SdkReticulumPreviewBehavior::DeferDeliveryPlans + } + }; + ReticulumPreviewProfile::preview_unavailable().with_behavior(behavior) +} + pub(crate) fn validate_proxy_bearer_token(config: &RuntimeConfig) -> Result<(), RuntimeError> { proxy_bearer_token(config).map(|_| ()) } @@ -720,8 +733,8 @@ mod tests { use super::*; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, - LocalConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, RhiConfig, - RpcConfig, SignerBackend, SignerConfig, Verbosity, + LocalConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, + ReticulumPreviewBehavior, RhiConfig, RpcConfig, SignerBackend, SignerConfig, Verbosity, }; struct DirectRrRsDependency { @@ -1180,6 +1193,31 @@ mod tests { } #[test] + fn maps_hybrid_runtime_config_to_sdk_hybrid_profile() { + let root = tempdir().expect("tempdir"); + let mut config = sample_config( + root.path(), + vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()], + ); + config.transport.profile = TransportProfileKind::Hybrid; + config.transport.reticulum_preview_behavior = ReticulumPreviewBehavior::DeferDeliveryPlans; + + let sdk_config = CliSdkConfig::from_runtime_config(&config).expect("sdk config"); + + let TransportProfile::Hybrid { profile } = sdk_config.transport_profile else { + panic!("expected Hybrid transport profile"); + }; + assert_eq!( + profile.nostr().relay_urls(), + vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] + ); + assert_eq!( + profile.reticulum_preview().behavior().as_str(), + "defer_delivery_plans" + ); + } + + #[test] fn maps_proxy_token_file_to_sdk_profile_auth() { let root = tempdir().expect("tempdir"); let mut config = sample_config(root.path(), Vec::new()); diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs @@ -35,8 +35,9 @@ use crate::runtime::sdk::{ sdk_nostr_relay_url_policy, }; use crate::view::runtime::{ - RelayFailureView, SyncActionView, SyncFreshnessView, SyncQueueView, SyncRunFreshnessView, - SyncStatusView, SyncWatchFrameView, SyncWatchView, + SyncActionView, SyncFreshnessView, SyncQueueView, SyncRunFreshnessView, SyncStatusView, + SyncTransportStatusView, SyncTransportTargetView, SyncWatchFrameView, SyncWatchView, + TransportTargetFailureView, }; const SYNC_SOURCE: &str = "local replica ยท local first"; @@ -83,7 +84,7 @@ struct SyncSnapshot { source: String, local_root: String, replica_db: String, - relay_count: usize, + configured_transport_target_count: usize, publish_policy: String, freshness: SyncFreshnessView, queue: SyncQueueView, @@ -95,9 +96,9 @@ struct SyncSnapshot { struct SyncRunRecord { scope: String, relay_set_fingerprint: String, - target_relays_json: String, - connected_relays_json: String, - failed_relays_json: String, + target_transport_endpoints_json: String, + attempted_transport_endpoints_json: String, + failed_transport_targets_json: String, started_at: u64, completed_at: Option<u64>, state: String, @@ -113,9 +114,9 @@ struct SyncRunRecord { struct SyncRunRow { scope: String, relay_set_fingerprint: String, - target_relays_json: String, - connected_relays_json: String, - failed_relays_json: String, + target_transport_endpoints_json: String, + attempted_transport_endpoints_json: String, + failed_transport_targets_json: String, started_at: i64, completed_at: Option<i64>, state: String, @@ -223,7 +224,7 @@ where let mut view = empty_action_from_snapshot(snapshot, "pull"); view.state = "ready".to_owned(); view.reason = Some("dry run requested; relay fetch skipped".to_owned()); - view.target_relays = config.transport.nostr_relay_urls.clone(); + view.target_transport_endpoints = config.transport.nostr_relay_urls.clone(); view.fetched_count = Some(0); view.ingested_count = Some(0); view.publishable_count = None; @@ -239,9 +240,9 @@ where let started_at = unix_now(); let receipt = match fetcher(&config.transport.nostr_relay_urls, scope.filter()) { Ok(receipt) if receipt.connected_relays.is_empty() && !receipt.failed_relays.is_empty() => { - let target_relays = receipt.target_relays; - let failed_relays = relay_failures(receipt.failed_relays); - let reason = relay_failure_reason(&failed_relays); + let target_transport_endpoints = receipt.target_relays; + let failed_transport_targets = relay_failures(receipt.failed_relays); + let reason = relay_failure_reason(&failed_transport_targets); let failure_reason = format!("relay transport fetch failed: {reason}"); let executor = SqliteExecutor::open(&config.local.replica_db_path)?; migrations::run_all_up(&executor)?; @@ -250,8 +251,8 @@ where &sync_record_from_failure( scope, &config.transport.nostr_relay_urls, - target_relays.clone(), - failed_relays.clone(), + target_transport_endpoints.clone(), + failed_transport_targets.clone(), started_at, failure_reason.clone(), )?, @@ -260,8 +261,8 @@ where view.state = "unavailable".to_owned(); view.reason = Some(failure_reason); view.reason_code = Some("relay_fetch_failed".to_owned()); - view.target_relays = target_relays; - view.failed_relays = failed_relays; + view.target_transport_endpoints = target_transport_endpoints; + view.failed_transport_targets = failed_transport_targets; view.freshness = freshness_for_scope_from_executor(config, &executor, scope)?; return Ok(view); } @@ -285,7 +286,7 @@ where view.state = "unavailable".to_owned(); view.reason = Some(failure_reason); view.reason_code = Some("relay_fetch_failed".to_owned()); - view.target_relays = config.transport.nostr_relay_urls.clone(); + view.target_transport_endpoints = config.transport.nostr_relay_urls.clone(); view.freshness = freshness_for_scope_from_executor(config, &executor, scope)?; return Ok(view); } @@ -304,10 +305,11 @@ where started_at, )?, )?; - let failed_relays = relay_failures(receipt.failed_relays); - let failed_count = ingest.failed_count + failed_relays.len(); - let reason_code = relay_ingest_reason_code(&ingest, &failed_relays).map(str::to_owned); - let reason = relay_ingest_reason(&ingest, &failed_relays); + let failed_transport_targets = relay_failures(receipt.failed_relays); + let failed_count = ingest.failed_count + failed_transport_targets.len(); + let reason_code = + relay_ingest_reason_code(&ingest, &failed_transport_targets).map(str::to_owned); + let reason = relay_ingest_reason(&ingest, &failed_transport_targets); let freshness = freshness_for_scope_from_executor(config, &executor, scope)?; let queue = radroots_replica_sync_status(&executor)?; @@ -317,14 +319,15 @@ where source: INGEST_SOURCE.to_owned(), local_root: config.local.root.display().to_string(), replica_db: "ready".to_owned(), - relay_count: config.transport.nostr_relay_urls.len(), + configured_transport_target_count: config.transport.nostr_relay_urls.len(), + transport_statuses: Vec::new(), publish_policy: "any".to_owned(), freshness, queue: derived_projection_sync_queue(queue.expected_count, queue.pending_count), - target_relays: receipt.target_relays, - connected_relays: receipt.connected_relays, - acknowledged_relays: Vec::new(), - failed_relays, + target_transport_endpoints: receipt.target_relays, + attempted_transport_endpoints: receipt.connected_relays, + accepted_transport_endpoints: Vec::new(), + failed_transport_targets, fetched_count: Some(ingest.fetched_count), ingested_count: Some(ingest.ingested_count), publishable_count: None, @@ -369,7 +372,7 @@ pub fn watch(config: &RuntimeConfig, args: &SyncWatchArgs) -> Result<SyncWatchVi sequence: index + 1, observed_at: unix_now(), state: snapshot.state.clone(), - relay_count: snapshot.relay_count, + configured_transport_target_count: snapshot.configured_transport_target_count, freshness: snapshot.freshness.clone(), queue: snapshot.queue.clone(), }); @@ -398,14 +401,15 @@ fn empty_action_from_snapshot(snapshot: SyncSnapshot, direction: &str) -> SyncAc source: snapshot.source, local_root: snapshot.local_root, replica_db: snapshot.replica_db, - relay_count: snapshot.relay_count, + configured_transport_target_count: snapshot.configured_transport_target_count, + transport_statuses: Vec::new(), publish_policy: snapshot.publish_policy, freshness: snapshot.freshness, queue: snapshot.queue, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), + target_transport_endpoints: Vec::new(), + attempted_transport_endpoints: Vec::new(), + accepted_transport_endpoints: Vec::new(), + failed_transport_targets: Vec::new(), fetched_count: None, ingested_count: None, publishable_count: None, @@ -422,13 +426,18 @@ fn empty_action_from_snapshot(snapshot: SyncSnapshot, direction: &str) -> SyncAc fn sdk_sync_status_view(config: &RuntimeConfig, receipt: SyncStatusReceipt) -> SyncStatusView { let actions = sdk_sync_status_actions(&receipt); - let relay_count = receipt.transport_profile.configured_nostr_relay_count; + let configured_transport_target_count = + receipt.transport_profile.configured_transport_target_count; + let configured_transport_targets = sdk_transport_targets(&receipt); + let transport_statuses = sdk_transport_statuses(&receipt); SyncStatusView { state: "ready".to_owned(), source: SDK_SYNC_SOURCE.to_owned(), local_root: config.local.root.display().to_string(), replica_db: "derived_projection_not_checked".to_owned(), - relay_count, + configured_transport_target_count, + configured_transport_targets, + transport_statuses, publish_policy: "any".to_owned(), freshness: sdk_sync_freshness(&receipt), queue: sdk_sync_queue(&receipt), @@ -456,7 +465,14 @@ fn sdk_push_dry_run_view(config: &RuntimeConfig, status: SyncStatusReceipt) -> S state, sdk_sync_queue(&status), sdk_sync_freshness(&status), - status.transport_profile.configured_nostr_relays, + status.transport_profile.configured_transport_target_count, + sdk_transport_statuses(&status), + status + .transport_profile + .configured_transport_targets + .iter() + .map(|target| target.endpoint_uri.clone()) + .collect(), Vec::new(), Vec::new(), Vec::new(), @@ -492,10 +508,12 @@ fn sdk_push_view( state, sdk_sync_queue(&status), sdk_sync_freshness(&status), - sdk_push_target_relays(&receipt, &status), - sdk_push_connected_relays(&receipt), - sdk_push_acknowledged_relays(&receipt), - sdk_push_failed_relays(&receipt), + status.transport_profile.configured_transport_target_count, + sdk_transport_statuses(&status), + sdk_push_target_transport_endpoints(&receipt, &status), + sdk_push_attempted_transport_endpoints(&receipt), + sdk_push_accepted_transport_endpoints(&receipt), + sdk_push_failed_transport_targets(&receipt), receipt.attempted_events, receipt.published_events, failed_count, @@ -510,10 +528,12 @@ fn sdk_push_action_view( state: &str, queue: SyncQueueView, freshness: SyncFreshnessView, - target_relays: Vec<String>, - connected_relays: Vec<String>, - acknowledged_relays: Vec<String>, - failed_relays: Vec<RelayFailureView>, + configured_transport_target_count: usize, + transport_statuses: Vec<SyncTransportStatusView>, + target_transport_endpoints: Vec<String>, + attempted_transport_endpoints: Vec<String>, + accepted_transport_endpoints: Vec<String>, + failed_transport_targets: Vec<TransportTargetFailureView>, publishable_count: usize, published_count: usize, failed_count: usize, @@ -527,14 +547,15 @@ fn sdk_push_action_view( source: SDK_PUSH_SOURCE.to_owned(), local_root: config.local.root.display().to_string(), replica_db: "derived_projection_not_checked".to_owned(), - relay_count: config.transport.nostr_relay_urls.len(), + configured_transport_target_count, + transport_statuses, publish_policy: "any".to_owned(), freshness, queue, - target_relays, - connected_relays, - acknowledged_relays, - failed_relays, + target_transport_endpoints, + attempted_transport_endpoints, + accepted_transport_endpoints, + failed_transport_targets, fetched_count: None, ingested_count: None, publishable_count: Some(publishable_count), @@ -549,6 +570,37 @@ fn sdk_push_action_view( } } +fn sdk_transport_targets(receipt: &SyncStatusReceipt) -> Vec<SyncTransportTargetView> { + receipt + .transport_profile + .configured_transport_targets + .iter() + .map(|target| SyncTransportTargetView { + transport_kind: target.transport_kind.clone(), + endpoint_uri: target.endpoint_uri.clone(), + endpoint_fingerprint: target.endpoint_fingerprint.clone(), + }) + .collect() +} + +fn sdk_transport_statuses(receipt: &SyncStatusReceipt) -> Vec<SyncTransportStatusView> { + receipt + .transport_profile + .transport_statuses + .iter() + .map(|status| SyncTransportStatusView { + transport_kind: status.transport_kind.clone(), + profile_id: status.profile_id.clone(), + endpoint_uri: status.endpoint_uri.clone(), + implementation_state: status.implementation_state.clone(), + readiness: status.readiness.clone(), + publish_usable: status.publish_usable, + fetch_usable: status.fetch_usable, + redacted_message: status.redacted_message.clone(), + }) + .collect() +} + fn sdk_sync_status_actions(receipt: &SyncStatusReceipt) -> Vec<String> { let mut actions = Vec::new(); if receipt.outbox.ready_signed_events > 0 { @@ -658,7 +710,10 @@ fn sdk_sync_freshness(receipt: &SyncStatusReceipt) -> SyncFreshnessView { } } -fn sdk_push_target_relays(receipt: &PushOutboxReceipt, status: &SyncStatusReceipt) -> Vec<String> { +fn sdk_push_target_transport_endpoints( + receipt: &PushOutboxReceipt, + status: &SyncStatusReceipt, +) -> Vec<String> { let mut targets = Vec::new(); for target in receipt.events.iter().flat_map(|event| event.targets.iter()) { if !targets.contains(&target.endpoint_uri) { @@ -666,16 +721,22 @@ fn sdk_push_target_relays(receipt: &PushOutboxReceipt, status: &SyncStatusReceip } } if targets.is_empty() { - targets.extend(status.transport_profile.configured_nostr_relays.clone()); + targets.extend( + status + .transport_profile + .configured_transport_targets + .iter() + .map(|target| target.endpoint_uri.clone()), + ); } targets } -fn sdk_push_connected_relays(receipt: &PushOutboxReceipt) -> Vec<String> { +fn sdk_push_attempted_transport_endpoints(receipt: &PushOutboxReceipt) -> Vec<String> { sdk_push_targets_matching(receipt, |_, target| target.attempted) } -fn sdk_push_acknowledged_relays(receipt: &PushOutboxReceipt) -> Vec<String> { +fn sdk_push_accepted_transport_endpoints(receipt: &PushOutboxReceipt) -> Vec<String> { sdk_push_targets_matching(receipt, |_, target| { sdk_target_accepted(target.outcome_kind) }) @@ -696,14 +757,17 @@ fn sdk_push_targets_matching( targets } -fn sdk_push_failed_relays(receipt: &PushOutboxReceipt) -> Vec<RelayFailureView> { +fn sdk_push_failed_transport_targets( + receipt: &PushOutboxReceipt, +) -> Vec<TransportTargetFailureView> { receipt .events .iter() .flat_map(|event| event.targets.iter()) .filter(|target| !sdk_target_accepted(target.outcome_kind)) - .map(|target| RelayFailureView { - relay: target.endpoint_uri.clone(), + .map(|target| TransportTargetFailureView { + transport_kind: target.transport_kind.clone(), + endpoint_uri: target.endpoint_uri.clone(), reason: target .message .clone() @@ -749,7 +813,7 @@ fn inspect_sync(config: &RuntimeConfig) -> Result<SyncSnapshot, RuntimeError> { source: SYNC_SOURCE.to_owned(), local_root: config.local.root.display().to_string(), replica_db: "missing".to_owned(), - relay_count: config.transport.nostr_relay_urls.len(), + configured_transport_target_count: config.transport.nostr_relay_urls.len(), publish_policy: "any".to_owned(), freshness: missing_freshness(), queue: derived_projection_sync_queue(0, 0), @@ -763,18 +827,18 @@ fn inspect_sync(config: &RuntimeConfig) -> Result<SyncSnapshot, RuntimeError> { let queue = radroots_replica_sync_status(&executor)?; let freshness = freshness_for_scope_from_executor(config, &executor, RelayIngestScope::SyncPull)?; - let relay_count = config.transport.nostr_relay_urls.len(); + let configured_transport_target_count = config.transport.nostr_relay_urls.len(); let publish_policy = "any".to_owned(); let mut actions = Vec::new(); - if relay_count == 0 { + if configured_transport_target_count == 0 { actions.push(RELAY_PULL_SETUP_ACTION.to_owned()); return Ok(SyncSnapshot { state: "unconfigured".to_owned(), source: SYNC_SOURCE.to_owned(), local_root: config.local.root.display().to_string(), replica_db: "ready".to_owned(), - relay_count, + configured_transport_target_count, publish_policy, freshness, queue: derived_projection_sync_queue(queue.expected_count, queue.pending_count), @@ -793,7 +857,7 @@ fn inspect_sync(config: &RuntimeConfig) -> Result<SyncSnapshot, RuntimeError> { source: SYNC_SOURCE.to_owned(), local_root: config.local.root.display().to_string(), replica_db: "ready".to_owned(), - relay_count, + configured_transport_target_count, publish_policy, freshness, queue: derived_projection_sync_queue(queue.expected_count, queue.pending_count), @@ -839,7 +903,8 @@ pub(crate) fn relay_provenance_relays_for_scope( if run.relay_set_fingerprint != current_fingerprint || !sync_run_successful(&run) { return Ok(Vec::new()); } - let mut relays: Vec<String> = serde_json::from_str(run.connected_relays_json.as_str())?; + let mut relays: Vec<String> = + serde_json::from_str(run.attempted_transport_endpoints_json.as_str())?; relays.sort(); relays.dedup(); Ok(relays) @@ -980,9 +1045,9 @@ pub(crate) fn ensure_sync_run_table(executor: &SqliteExecutor) -> Result<(), Run id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, relay_set_fingerprint TEXT NOT NULL, - target_relays_json TEXT NOT NULL, - connected_relays_json TEXT NOT NULL, - failed_relays_json TEXT NOT NULL, + target_transport_endpoints_json TEXT NOT NULL, + attempted_transport_endpoints_json TEXT NOT NULL, + failed_transport_targets_json TEXT NOT NULL, started_at INTEGER NOT NULL, completed_at INTEGER, state TEXT NOT NULL, @@ -1008,9 +1073,9 @@ fn latest_sync_run( &format!( "SELECT scope, relay_set_fingerprint, - target_relays_json, - connected_relays_json, - failed_relays_json, + target_transport_endpoints_json, + attempted_transport_endpoints_json, + failed_transport_targets_json, started_at, completed_at, state, @@ -1035,9 +1100,9 @@ fn sync_run_record_from_row(row: SyncRunRow) -> SyncRunRecord { SyncRunRecord { scope: row.scope, relay_set_fingerprint: row.relay_set_fingerprint, - target_relays_json: row.target_relays_json, - connected_relays_json: row.connected_relays_json, - failed_relays_json: row.failed_relays_json, + target_transport_endpoints_json: row.target_transport_endpoints_json, + attempted_transport_endpoints_json: row.attempted_transport_endpoints_json, + failed_transport_targets_json: row.failed_transport_targets_json, started_at: u64_from_db(row.started_at), completed_at: row.completed_at.map(u64_from_db), state: row.state, @@ -1057,9 +1122,9 @@ fn record_sync_run(executor: &SqliteExecutor, record: &SyncRunRecord) -> Result< "INSERT INTO {SYNC_RUN_TABLE} ( scope, relay_set_fingerprint, - target_relays_json, - connected_relays_json, - failed_relays_json, + target_transport_endpoints_json, + attempted_transport_endpoints_json, + failed_transport_targets_json, started_at, completed_at, state, @@ -1074,9 +1139,9 @@ fn record_sync_run(executor: &SqliteExecutor, record: &SyncRunRecord) -> Result< json!([ record.scope.as_str(), record.relay_set_fingerprint.as_str(), - record.target_relays_json.as_str(), - record.connected_relays_json.as_str(), - record.failed_relays_json.as_str(), + record.target_transport_endpoints_json.as_str(), + record.attempted_transport_endpoints_json.as_str(), + record.failed_transport_targets_json.as_str(), i64_from_u64(record.started_at), record.completed_at.map(i64_from_u64), record.state.as_str(), @@ -1096,17 +1161,17 @@ fn record_sync_run(executor: &SqliteExecutor, record: &SyncRunRecord) -> Result< fn sync_record_from_failure( scope: RelayIngestScope, relays: &[String], - target_relays: Vec<String>, - failed_relays: Vec<RelayFailureView>, + target_transport_endpoints: Vec<String>, + failed_transport_targets: Vec<TransportTargetFailureView>, started_at: u64, reason: String, ) -> Result<SyncRunRecord, RuntimeError> { Ok(SyncRunRecord { scope: scope.id().to_owned(), relay_set_fingerprint: relay_set_fingerprint(relays), - target_relays_json: serde_json::to_string(&target_relays)?, - connected_relays_json: serde_json::to_string(&Vec::<String>::new())?, - failed_relays_json: serde_json::to_string(&failed_relays)?, + target_transport_endpoints_json: serde_json::to_string(&target_transport_endpoints)?, + attempted_transport_endpoints_json: serde_json::to_string(&Vec::<String>::new())?, + failed_transport_targets_json: serde_json::to_string(&failed_transport_targets)?, started_at, completed_at: Some(unix_now()), state: "failed".to_owned(), @@ -1126,8 +1191,8 @@ fn sync_record_from_ingest( ingest: &RelayIngestCounts, started_at: u64, ) -> Result<SyncRunRecord, RuntimeError> { - let failed_relays = relay_failures(receipt.failed_relays.clone()); - let state = if ingest.failed_count > 0 || !failed_relays.is_empty() { + let failed_transport_targets = relay_failures(receipt.failed_relays.clone()); + let state = if ingest.failed_count > 0 || !failed_transport_targets.is_empty() { "partial" } else { "success" @@ -1135,9 +1200,9 @@ fn sync_record_from_ingest( Ok(SyncRunRecord { scope: scope.id().to_owned(), relay_set_fingerprint: relay_set_fingerprint(relays), - target_relays_json: serde_json::to_string(&receipt.target_relays)?, - connected_relays_json: serde_json::to_string(&receipt.connected_relays)?, - failed_relays_json: serde_json::to_string(&failed_relays)?, + target_transport_endpoints_json: serde_json::to_string(&receipt.target_relays)?, + attempted_transport_endpoints_json: serde_json::to_string(&receipt.connected_relays)?, + failed_transport_targets_json: serde_json::to_string(&failed_transport_targets)?, started_at, completed_at: Some(unix_now()), state: state.to_owned(), @@ -1145,7 +1210,7 @@ fn sync_record_from_ingest( ingested_count: ingest.ingested_count, skipped_count: ingest.skipped_count, unsupported_count: ingest.unsupported_count, - failed_count: ingest.failed_count + failed_relays.len(), + failed_count: ingest.failed_count + failed_transport_targets.len(), failure_reason: ingest.reason(), }) } @@ -1229,26 +1294,26 @@ impl RelayIngestCounts { fn relay_ingest_reason_code( ingest: &RelayIngestCounts, - failed_relays: &[RelayFailureView], + failed_transport_targets: &[TransportTargetFailureView], ) -> Option<&'static str> { ingest .reason_code() - .or_else(|| (!failed_relays.is_empty()).then_some("relay_fetch_partial")) + .or_else(|| (!failed_transport_targets.is_empty()).then_some("relay_fetch_partial")) } fn relay_ingest_reason( ingest: &RelayIngestCounts, - failed_relays: &[RelayFailureView], + failed_transport_targets: &[TransportTargetFailureView], ) -> Option<String> { let mut parts = Vec::new(); if let Some(reason) = ingest.reason() { parts.push(reason); } - if !failed_relays.is_empty() { + if !failed_transport_targets.is_empty() { parts.push(format!( "{} relay(s) failed during fetch: {}", - failed_relays.len(), - relay_failure_reason(failed_relays) + failed_transport_targets.len(), + relay_failure_reason(failed_transport_targets) )); } @@ -1259,10 +1324,10 @@ fn relay_ingest_reason( } } -fn relay_failure_reason(failed_relays: &[RelayFailureView]) -> String { - failed_relays +fn relay_failure_reason(failed_transport_targets: &[TransportTargetFailureView]) -> String { + failed_transport_targets .iter() - .map(|failure| format!("{}: {}", failure.relay, failure.reason)) + .map(|failure| format!("{}: {}", failure.endpoint_uri, failure.reason)) .collect::<Vec<_>>() .join("; ") } @@ -1361,11 +1426,12 @@ fn event_kind(event: &radroots_nostr::prelude::RadrootsNostrEvent) -> u32 { u32::from(event.kind.as_u16()) } -fn relay_failures(failures: Vec<RadrootsRelayFetchFailure>) -> Vec<RelayFailureView> { +fn relay_failures(failures: Vec<RadrootsRelayFetchFailure>) -> Vec<TransportTargetFailureView> { failures .into_iter() - .map(|failure| RelayFailureView { - relay: failure.relay_url, + .map(|failure| TransportTargetFailureView { + transport_kind: "nostr".to_owned(), + endpoint_uri: failure.relay_url, reason: failure.reason, }) .collect() @@ -1461,6 +1527,7 @@ mod tests { PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxTargetOutcomeKind, PushOutboxTargetReceipt, SyncEventStoreStatus, SyncOutboxStatus, SyncStatusReceipt, SyncStatusSource, SyncTransportProfileSummary, + SyncTransportStatusSummary, SyncTransportTargetSummary, }; use radroots_secret_vault::RadrootsSecretBackend; use radroots_transport_nostr::{ @@ -1496,7 +1563,10 @@ mod tests { .expect("sync pull dry run"); assert_eq!(view.state, "ready"); - assert_eq!(view.target_relays, vec!["wss://relay.example.com"]); + assert_eq!( + view.target_transport_endpoints, + vec!["wss://relay.example.com"] + ); assert_eq!(view.fetched_count, Some(0)); assert_eq!(view.ingested_count, Some(0)); assert_eq!(view.skipped_count, Some(0)); @@ -1557,7 +1627,7 @@ mod tests { assert_eq!(view.state, "ready"); assert_eq!(view.source, "SDK canonical event store and outbox"); assert_eq!(view.replica_db, "derived_projection_not_checked"); - assert_eq!(view.relay_count, 2); + assert_eq!(view.configured_transport_target_count, 2); assert_eq!(view.queue.total_count, Some(0)); assert_eq!(view.queue.pending_count, 0); assert_eq!(view.queue.retryable_count, Some(0)); @@ -1637,7 +1707,10 @@ mod tests { assert_eq!(view.state, "dry_run"); assert_eq!(view.source, "SDK outbox push"); assert_eq!(view.replica_db, "derived_projection_not_checked"); - assert_eq!(view.target_relays, vec!["wss://relay.example.com"]); + assert_eq!( + view.target_transport_endpoints, + vec!["wss://relay.example.com"] + ); assert_eq!(view.publishable_count, Some(1)); assert_eq!(view.published_count, Some(0)); assert_eq!(view.failed_count, Some(0)); @@ -1734,26 +1807,33 @@ mod tests { assert_eq!(view.failed_count, Some(1)); assert_eq!(view.reason_code.as_deref(), Some("sdk_outbox_push_partial")); assert_eq!( - view.target_relays, + view.target_transport_endpoints, vec![ "wss://relay-a.example.com".to_owned(), "wss://relay-b.example.com".to_owned() ] ); assert_eq!( - view.connected_relays, + view.attempted_transport_endpoints, vec![ "wss://relay-a.example.com".to_owned(), "wss://relay-b.example.com".to_owned() ] ); assert_eq!( - view.acknowledged_relays, + view.accepted_transport_endpoints, vec!["wss://relay-a.example.com".to_owned()] ); - assert_eq!(view.failed_relays.len(), 1); - assert_eq!(view.failed_relays[0].relay, "wss://relay-b.example.com"); - assert_eq!(view.failed_relays[0].reason, "auth-required: login"); + assert_eq!(view.failed_transport_targets.len(), 1); + assert_eq!( + view.failed_transport_targets[0].endpoint_uri, + "wss://relay-b.example.com" + ); + assert_eq!(view.failed_transport_targets[0].transport_kind, "nostr"); + assert_eq!( + view.failed_transport_targets[0].reason, + "auth-required: login" + ); assert_eq!( view.actions, vec!["radroots sync push", "radroots sync status get"] @@ -1800,8 +1880,26 @@ mod tests { }, transport_profile: SyncTransportProfileSummary { transport_profile_id: "nostr".to_owned(), - configured_nostr_relay_count: relays.len(), - configured_nostr_relays: relays.iter().map(|relay| (*relay).to_owned()).collect(), + configured_transport_target_count: relays.len(), + configured_transport_targets: relays + .iter() + .enumerate() + .map(|(index, relay)| SyncTransportTargetSummary { + transport_kind: "nostr".to_owned(), + endpoint_uri: (*relay).to_owned(), + endpoint_fingerprint: format!("test-fingerprint-{index}"), + }) + .collect(), + transport_statuses: vec![SyncTransportStatusSummary { + transport_kind: "nostr".to_owned(), + profile_id: Some("nostr".to_owned()), + endpoint_uri: None, + implementation_state: "available".to_owned(), + readiness: "ready".to_owned(), + publish_usable: true, + fetch_usable: true, + redacted_message: None, + }], }, } } @@ -1997,8 +2095,11 @@ mod tests { .expect("sync pull partial relay fetch"); assert_eq!(view.state, "ready"); - assert_eq!(view.connected_relays, vec!["wss://relay-a.example.com"]); - assert_eq!(view.failed_relays.len(), 1); + assert_eq!( + view.attempted_transport_endpoints, + vec!["wss://relay-a.example.com"] + ); + assert_eq!(view.failed_transport_targets.len(), 1); assert_eq!(view.failed_count, Some(1)); assert_eq!(view.reason_code.as_deref(), Some("relay_fetch_partial")); assert!( diff --git a/src/runtime/transport.rs b/src/runtime/transport.rs @@ -49,6 +49,25 @@ pub fn set_profile( preview.insert("behavior".to_owned(), Value::String(behavior.to_owned())); transport.insert("reticulum_preview".to_owned(), Value::Table(preview)); } + "hybrid" => { + let relays = string_array_input(input, "nostr_relays"); + if relays.is_empty() { + return Err(RuntimeError::Config( + "transport profile `hybrid` requires at least one --nostr-relay".to_owned(), + )); + } + let behavior = string_input(input, "reticulum_preview_behavior") + .unwrap_or("reject_delivery_attempts"); + let mut nostr = Map::new(); + nostr.insert( + "relay_urls".to_owned(), + Value::Array(relays.into_iter().map(Value::String).collect()), + ); + let mut preview = Map::new(); + preview.insert("behavior".to_owned(), Value::String(behavior.to_owned())); + transport.insert("nostr".to_owned(), Value::Table(nostr)); + transport.insert("reticulum_preview".to_owned(), Value::Table(preview)); + } "proxy" => { let Some(url) = string_input(input, "proxy_url") else { return Err(RuntimeError::Config( @@ -100,7 +119,10 @@ pub fn set_profile( pub fn status(config: &RuntimeConfig) -> TransportStatusView { let mut transports = vec![active_profile_view(config)]; - if config.transport.profile != TransportProfileKind::ReticulumPreview { + if !matches!( + config.transport.profile, + TransportProfileKind::ReticulumPreview | TransportProfileKind::Hybrid + ) { transports.push(profile_view_from_parts( "reticulum_preview", Vec::new(), @@ -232,6 +254,29 @@ fn active_profile_view(config: &RuntimeConfig) -> TransportProfileView { None, "preview_unavailable", ), + TransportProfileKind::Hybrid => profile_view_from_parts( + "hybrid", + config.transport.nostr_relay_urls.clone(), + Some( + config + .transport + .reticulum_preview_behavior + .as_str() + .to_owned(), + ), + None, + None, + None, + if config.transport.nostr_relay_urls.is_empty() { + "unconfigured" + } else { + "configured" + }, + ) + .with_message( + "Hybrid transport publishes through configured Nostr relays and reports Reticulum preview status" + .to_owned(), + ), TransportProfileKind::Proxy => { let proxy_readiness = proxy_token_ready(config); profile_view_from_parts( @@ -272,21 +317,27 @@ fn profile_view_from_parts( let transport_kind = match profile_id { "nostr" => "nostr", "reticulum_preview" => "reticulum", + "hybrid" => "hybrid", "proxy" => "proxy", _ => "local", }; let implementation_state = match profile_id { "nostr" => "available", "reticulum_preview" => "preview_unavailable", + "hybrid" => "available_with_preview", "proxy" => "delegated", _ => "local_only", }; let usable_for_delivery = - matches!(profile_id, "nostr" | "proxy") && configured_state == "configured"; + matches!(profile_id, "nostr" | "hybrid" | "proxy") && configured_state == "configured"; let message = match profile_id { "nostr" if usable_for_delivery => "Nostr relay transport is configured for delivery", "nostr" => "Nostr transport requires configured Nostr relay targets", "reticulum_preview" => RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, + "hybrid" if usable_for_delivery => { + "Hybrid transport publishes through configured Nostr relays and reports Reticulum preview status" + } + "hybrid" => "Hybrid transport requires configured Nostr relay targets", "proxy" if usable_for_delivery => { "Proxy transport delegates delivery to the configured endpoint" } @@ -337,6 +388,12 @@ fn profile_actions(profile_id: &str, usable_for_delivery: bool) -> Vec<String> { .to_owned(), ] } + "hybrid" => { + vec![ + "radroots transport profile set --kind hybrid --nostr-relay wss://relay.example.com" + .to_owned(), + ] + } "proxy" => vec![ "radroots transport profile set --kind proxy --proxy-url http://127.0.0.1:7070 --proxy-token-file <path>" .to_owned(), diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -3447,7 +3447,11 @@ pub struct SyncStatusView { pub source: String, pub local_root: String, pub replica_db: String, - pub relay_count: usize, + pub configured_transport_target_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub configured_transport_targets: Vec<SyncTransportTargetView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub transport_statuses: Vec<SyncTransportStatusView>, pub publish_policy: String, pub freshness: SyncFreshnessView, pub queue: SyncQueueView, @@ -3475,18 +3479,20 @@ pub struct SyncActionView { pub source: String, pub local_root: String, pub replica_db: String, - pub relay_count: usize, + pub configured_transport_target_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub transport_statuses: Vec<SyncTransportStatusView>, pub publish_policy: String, pub freshness: SyncFreshnessView, pub queue: SyncQueueView, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_relays: Vec<String>, + pub target_transport_endpoints: Vec<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub connected_relays: Vec<String>, + pub attempted_transport_endpoints: Vec<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub acknowledged_relays: Vec<String>, + pub accepted_transport_endpoints: Vec<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub failed_relays: Vec<RelayFailureView>, + pub failed_transport_targets: Vec<TransportTargetFailureView>, #[serde(skip_serializing_if = "Option::is_none")] pub fetched_count: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] @@ -3511,6 +3517,35 @@ pub struct SyncActionView { pub actions: Vec<String>, } +#[derive(Debug, Clone, Serialize)] +pub struct SyncTransportTargetView { + pub transport_kind: String, + pub endpoint_uri: String, + pub endpoint_fingerprint: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SyncTransportStatusView { + pub transport_kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint_uri: Option<String>, + pub implementation_state: String, + pub readiness: String, + pub publish_usable: bool, + pub fetch_usable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub redacted_message: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TransportTargetFailureView { + pub transport_kind: String, + pub endpoint_uri: String, + pub reason: String, +} + impl SyncActionView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { @@ -3579,7 +3614,7 @@ pub struct SyncWatchFrameView { pub sequence: usize, pub observed_at: u64, pub state: String, - pub relay_count: usize, + pub configured_transport_target_count: usize, pub freshness: SyncFreshnessView, pub queue: SyncQueueView, } diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1319,6 +1319,56 @@ fn transport_profile_reticulum_preview_output_is_transport_specific() { } #[test] +fn transport_profile_set_hybrid_persists_nostr_and_reticulum_preview_config() { + let sandbox = RadrootsCliSandbox::new(); + + let value = sandbox.json_success(&[ + "--format", + "json", + "transport", + "profile", + "set", + "--kind", + "hybrid", + "--nostr-relay", + "wss://relay.example.com", + "--reticulum-preview-behavior", + "defer-delivery-plans", + ]); + + assert_eq!(value["operation_id"], "transport.profile.set"); + assert_eq!(value["result"]["profile_id"], "hybrid"); + assert_eq!(value["result"]["transport_kind"], "hybrid"); + assert_eq!(value["result"]["state"], "configured"); + assert_eq!( + value["result"]["nostr_relays"][0], + "wss://relay.example.com" + ); + assert_eq!( + value["result"]["reticulum_preview_behavior"], + "defer_delivery_plans" + ); + + let value = sandbox.json_success(&["--format", "json", "transport", "profile", "get"]); + assert_eq!(value["result"]["profile_id"], "hybrid"); + assert_eq!( + value["result"]["nostr_relays"][0], + "wss://relay.example.com" + ); + assert_eq!( + value["result"]["reticulum_preview_behavior"], + "defer_delivery_plans" + ); + + let config = fs::read_to_string(sandbox.root().join("config/apps/cli/config.toml")) + .expect("read app config"); + assert!(config.contains("profile = \"hybrid\"")); + assert!(config.contains("[transport.nostr]")); + assert!(config.contains("[transport.reticulum_preview]")); + assert!(config.contains("behavior = \"defer_delivery_plans\"")); +} + +#[test] fn transport_status_reticulum_preview_output_reports_unusable_preview_state() { let sandbox = RadrootsCliSandbox::new(); sandbox.write_app_config( @@ -1453,7 +1503,8 @@ fn transport_source_boundary_rejects_removed_relay_and_publish_proxy_surfaces() fs::read_to_string(manifest_dir.join("src/runtime/transport.rs")).expect("read source"); for required in [ "RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE", - "if config.transport.profile != TransportProfileKind::ReticulumPreview", + "if !matches!(", + "TransportProfileKind::ReticulumPreview | TransportProfileKind::Hybrid", "validate_proxy_token_material", "proxy_token_ready(&validation_config)", "crate::runtime::sdk::validate_proxy_bearer_token(config)", @@ -6607,9 +6658,12 @@ fn sync_push_sdk_outbox_failure_reports_network_unavailable() { value["errors"][0]["detail"]["replica_db"], "derived_projection_not_checked" ); - assert_eq!(value["errors"][0]["detail"]["target_relays"][0], relay); assert_eq!( - value["errors"][0]["detail"]["failed_relays"][0]["relay"], + value["errors"][0]["detail"]["target_transport_endpoints"][0], + relay + ); + assert_eq!( + value["errors"][0]["detail"]["failed_transport_targets"][0]["endpoint_uri"], relay ); assert_eq!(value["errors"][0]["detail"]["publishable_count"], 1); @@ -6717,7 +6771,7 @@ fn buyer_market_sync_basket_dry_runs_preflight_without_mutating_local_state() { assert_eq!(relay_refresh["dry_run"], true); assert_eq!(relay_refresh["result"]["state"], "ready"); assert_eq!( - relay_refresh["result"]["target_relays"][0], + relay_refresh["result"]["target_transport_endpoints"][0], "ws://127.0.0.1:9" ); assert_eq!(relay_refresh["result"]["fetched_count"], 0); @@ -6733,7 +6787,7 @@ fn buyer_market_sync_basket_dry_runs_preflight_without_mutating_local_state() { "derived_projection_not_checked" ); assert_eq!( - sync_push_ready["result"]["target_relays"][0], + sync_push_ready["result"]["target_transport_endpoints"][0], "ws://127.0.0.1:9" ); assert_eq!(sync_push_ready["result"]["publishable_count"], 0);