cli

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

commit 9feb085c6cbf3471d35394712cd927dae9ebcc84
parent d8399844092f2c0ad5c4a083289126251717ed0c
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 10:43:29 +0000

transport: align sync metadata with profiles

- add configured transport targets to sync action output
- derive pull and inspect metadata from active transport profiles
- keep Nostr relay fetch endpoints scoped to pull detail
- cover Hybrid and Reticulum preview sync metadata behavior

Diffstat:
Msrc/runtime/sync.rs | 419+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/view/runtime.rs | 2++
Mtests/target_cli.rs | 2++
3 files changed, 406 insertions(+), 17 deletions(-)

diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs @@ -21,6 +21,11 @@ use radroots_sdk::{ PushOutboxTargetReceipt, SyncStatusReceipt, SyncStatusRequest, }; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; +use radroots_transport::{ + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, + RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportReadinessState, + RadrootsTransportStatus, RadrootsTransportTarget, +}; use radroots_transport_nostr::{ RadrootsRelayFetchFailure, RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError, }; @@ -29,7 +34,7 @@ use serde_json::json; use crate::cli::global::SyncWatchArgs; use crate::runtime::RuntimeError; -use crate::runtime::config::RuntimeConfig; +use crate::runtime::config::{RuntimeConfig, TransportProfileKind}; use crate::runtime::sdk::{ CliSdkAdapterError, CliSdkSession, fetch_relay_events_via_shared_transport, sdk_nostr_relay_url_policy, @@ -85,6 +90,8 @@ struct SyncSnapshot { local_root: String, replica_db: String, configured_transport_target_count: usize, + configured_transport_targets: Vec<SyncTransportTargetView>, + transport_statuses: Vec<SyncTransportStatusView>, publish_policy: String, freshness: SyncFreshnessView, queue: SyncQueueView, @@ -92,6 +99,12 @@ struct SyncSnapshot { actions: Vec<String>, } +struct SyncTransportMetadata { + configured_transport_target_count: usize, + configured_transport_targets: Vec<SyncTransportTargetView>, + transport_statuses: Vec<SyncTransportStatusView>, +} + #[derive(Debug, Clone)] struct SyncRunRecord { scope: String, @@ -216,7 +229,7 @@ where ) -> Result<RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError>, { let snapshot = inspect_sync(config)?; - if snapshot.state == "unconfigured" { + if snapshot.state != "ready" { return Ok(empty_action_from_snapshot(snapshot, "pull")); } @@ -319,8 +332,9 @@ where source: INGEST_SOURCE.to_owned(), local_root: config.local.root.display().to_string(), replica_db: "ready".to_owned(), - configured_transport_target_count: config.transport.nostr_relay_urls.len(), - transport_statuses: Vec::new(), + configured_transport_target_count: snapshot.configured_transport_target_count, + configured_transport_targets: snapshot.configured_transport_targets, + transport_statuses: snapshot.transport_statuses, publish_policy: "any".to_owned(), freshness, queue: derived_projection_sync_queue(queue.expected_count, queue.pending_count), @@ -402,7 +416,8 @@ fn empty_action_from_snapshot(snapshot: SyncSnapshot, direction: &str) -> SyncAc local_root: snapshot.local_root, replica_db: snapshot.replica_db, configured_transport_target_count: snapshot.configured_transport_target_count, - transport_statuses: Vec::new(), + configured_transport_targets: snapshot.configured_transport_targets, + transport_statuses: snapshot.transport_statuses, publish_policy: snapshot.publish_policy, freshness: snapshot.freshness, queue: snapshot.queue, @@ -466,6 +481,7 @@ fn sdk_push_dry_run_view(config: &RuntimeConfig, status: SyncStatusReceipt) -> S sdk_sync_queue(&status), sdk_sync_freshness(&status), status.transport_profile.configured_transport_target_count, + sdk_transport_targets(&status), sdk_transport_statuses(&status), status .transport_profile @@ -509,6 +525,7 @@ fn sdk_push_view( sdk_sync_queue(&status), sdk_sync_freshness(&status), status.transport_profile.configured_transport_target_count, + sdk_transport_targets(&status), sdk_transport_statuses(&status), sdk_push_target_transport_endpoints(&receipt, &status), sdk_push_attempted_transport_endpoints(&receipt), @@ -529,6 +546,7 @@ fn sdk_push_action_view( queue: SyncQueueView, freshness: SyncFreshnessView, configured_transport_target_count: usize, + configured_transport_targets: Vec<SyncTransportTargetView>, transport_statuses: Vec<SyncTransportStatusView>, target_transport_endpoints: Vec<String>, attempted_transport_endpoints: Vec<String>, @@ -548,6 +566,7 @@ fn sdk_push_action_view( local_root: config.local.root.display().to_string(), replica_db: "derived_projection_not_checked".to_owned(), configured_transport_target_count, + configured_transport_targets, transport_statuses, publish_policy: "any".to_owned(), freshness, @@ -806,14 +825,187 @@ fn usize_from_i64(value: i64) -> usize { usize::try_from(value.max(0)).unwrap_or(usize::MAX) } +fn sync_transport_metadata(config: &RuntimeConfig) -> Result<SyncTransportMetadata, RuntimeError> { + let configured_transport_targets = sync_configured_transport_targets(config)?; + Ok(SyncTransportMetadata { + configured_transport_target_count: configured_transport_targets.len(), + configured_transport_targets, + transport_statuses: sync_transport_statuses(config), + }) +} + +fn sync_configured_transport_targets( + config: &RuntimeConfig, +) -> Result<Vec<SyncTransportTargetView>, RuntimeError> { + let mut targets = Vec::new(); + match config.transport.profile { + TransportProfileKind::LocalOnly => {} + TransportProfileKind::Nostr => { + for relay_url in &config.transport.nostr_relay_urls { + targets.push(sync_transport_target_view( + RadrootsTransportKind::Nostr, + relay_url, + )?); + } + } + TransportProfileKind::ReticulumPreview => { + targets.push(sync_transport_target_view( + RadrootsTransportKind::Reticulum, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, + )?); + } + TransportProfileKind::Hybrid => { + for relay_url in &config.transport.nostr_relay_urls { + targets.push(sync_transport_target_view( + RadrootsTransportKind::Nostr, + relay_url, + )?); + } + targets.push(sync_transport_target_view( + RadrootsTransportKind::Reticulum, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, + )?); + } + TransportProfileKind::Proxy => { + targets.push(sync_transport_target_view( + RadrootsTransportKind::Proxy, + config.transport.proxy.url.as_str(), + )?); + } + } + Ok(targets) +} + +fn sync_transport_target_view( + kind: RadrootsTransportKind, + endpoint_uri: &str, +) -> Result<SyncTransportTargetView, RuntimeError> { + let target = RadrootsTransportTarget::new(kind, endpoint_uri) + .map_err(|error| RuntimeError::Config(format!("invalid transport target: {error}")))?; + Ok(SyncTransportTargetView { + transport_kind: target.kind.canonical_label(), + endpoint_uri: target.uri.as_str().to_owned(), + endpoint_fingerprint: target.fingerprint.as_str().to_owned(), + }) +} + +fn sync_transport_statuses(config: &RuntimeConfig) -> Vec<SyncTransportStatusView> { + let profile_id = config.transport.profile.as_str(); + match config.transport.profile { + TransportProfileKind::LocalOnly => vec![sync_transport_status_view( + RadrootsTransportStatus::new( + RadrootsTransportKind::Local, + RadrootsTransportImplementationState::Available, + RadrootsTransportReadinessState::Ready, + ) + .with_profile_id(profile_id), + )], + TransportProfileKind::Nostr => { + vec![sync_transport_status_view(nostr_transport_status( + profile_id, + ))] + } + TransportProfileKind::ReticulumPreview => { + vec![sync_transport_status_view( + reticulum_preview_transport_status(profile_id), + )] + } + TransportProfileKind::Hybrid => vec![ + sync_transport_status_view(nostr_transport_status(profile_id)), + sync_transport_status_view(reticulum_preview_transport_status(profile_id)), + ], + TransportProfileKind::Proxy => { + let auth_configured = config.transport.proxy.token_file.is_some() + || config.transport.proxy.token_secret_id.is_some(); + vec![sync_transport_status_view( + RadrootsTransportStatus::new( + RadrootsTransportKind::Proxy, + if auth_configured { + RadrootsTransportImplementationState::Available + } else { + RadrootsTransportImplementationState::Misconfigured + }, + if auth_configured { + RadrootsTransportReadinessState::Ready + } else { + RadrootsTransportReadinessState::Misconfigured + }, + ) + .with_profile_id(profile_id) + .with_endpoint_uri(config.transport.proxy.url.as_str()) + .with_publish_usable(auth_configured), + )] + } + } +} + +fn nostr_transport_status(profile_id: &str) -> RadrootsTransportStatus { + RadrootsTransportStatus::new( + RadrootsTransportKind::Nostr, + RadrootsTransportImplementationState::Available, + RadrootsTransportReadinessState::Ready, + ) + .with_profile_id(profile_id) + .with_publish_usable(true) + .with_fetch_usable(true) +} + +fn reticulum_preview_transport_status(profile_id: &str) -> RadrootsTransportStatus { + RadrootsTransportStatus::new( + RadrootsTransportKind::Reticulum, + RadrootsTransportImplementationState::PreviewUnavailable, + RadrootsTransportReadinessState::PreviewUnavailable, + ) + .with_profile_id(profile_id) + .with_endpoint_uri(RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI) + .with_redacted_message(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE) +} + +fn sync_transport_status_view(status: RadrootsTransportStatus) -> SyncTransportStatusView { + SyncTransportStatusView { + transport_kind: status.kind.canonical_label(), + profile_id: status.profile_id, + endpoint_uri: status.endpoint_uri, + implementation_state: transport_implementation_state_label(status.implementation_state) + .to_owned(), + readiness: transport_readiness_state_label(status.readiness).to_owned(), + publish_usable: status.publish_usable, + fetch_usable: status.fetch_usable, + redacted_message: status.redacted_message, + } +} + +fn transport_implementation_state_label( + state: RadrootsTransportImplementationState, +) -> &'static str { + match state { + RadrootsTransportImplementationState::Available => "available", + RadrootsTransportImplementationState::Disabled => "disabled", + RadrootsTransportImplementationState::Misconfigured => "misconfigured", + RadrootsTransportImplementationState::PreviewUnavailable => "preview_unavailable", + } +} + +fn transport_readiness_state_label(state: RadrootsTransportReadinessState) -> &'static str { + match state { + RadrootsTransportReadinessState::Ready => "ready", + RadrootsTransportReadinessState::Disabled => "disabled", + RadrootsTransportReadinessState::Misconfigured => "misconfigured", + RadrootsTransportReadinessState::PreviewUnavailable => "preview_unavailable", + } +} + fn inspect_sync(config: &RuntimeConfig) -> Result<SyncSnapshot, RuntimeError> { + let transport_metadata = sync_transport_metadata(config)?; if !config.local.replica_db_path.exists() { return Ok(SyncSnapshot { state: "unconfigured".to_owned(), source: SYNC_SOURCE.to_owned(), local_root: config.local.root.display().to_string(), replica_db: "missing".to_owned(), - configured_transport_target_count: config.transport.nostr_relay_urls.len(), + configured_transport_target_count: transport_metadata.configured_transport_target_count, + configured_transport_targets: transport_metadata.configured_transport_targets, + transport_statuses: transport_metadata.transport_statuses, publish_policy: "any".to_owned(), freshness: missing_freshness(), queue: derived_projection_sync_queue(0, 0), @@ -827,22 +1019,40 @@ 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 configured_transport_target_count = config.transport.nostr_relay_urls.len(); let publish_policy = "any".to_owned(); let mut actions = Vec::new(); - if configured_transport_target_count == 0 { - actions.push(RELAY_PULL_SETUP_ACTION.to_owned()); + if config.transport.nostr_relay_urls.is_empty() { + let (state, reason) = if transport_metadata.configured_transport_target_count == 0 { + ( + "unconfigured", + "no transport targets are configured for this operator session", + ) + } else { + ( + "unavailable", + "active transport profile does not expose Nostr relay fetch targets", + ) + }; + actions.push( + if transport_metadata.configured_transport_target_count == 0 { + RELAY_PULL_SETUP_ACTION.to_owned() + } else { + "radroots transport profile get".to_owned() + }, + ); return Ok(SyncSnapshot { - state: "unconfigured".to_owned(), + state: state.to_owned(), source: SYNC_SOURCE.to_owned(), local_root: config.local.root.display().to_string(), replica_db: "ready".to_owned(), - configured_transport_target_count, + configured_transport_target_count: transport_metadata.configured_transport_target_count, + configured_transport_targets: transport_metadata.configured_transport_targets, + transport_statuses: transport_metadata.transport_statuses, publish_policy, freshness, queue: derived_projection_sync_queue(queue.expected_count, queue.pending_count), - reason: Some("no relays are configured for this operator session".to_owned()), + reason: Some(reason.to_owned()), actions, }); } @@ -857,7 +1067,9 @@ 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(), - configured_transport_target_count, + configured_transport_target_count: transport_metadata.configured_transport_target_count, + configured_transport_targets: transport_metadata.configured_transport_targets, + transport_statuses: transport_metadata.transport_statuses, publish_policy, freshness, queue: derived_projection_sync_queue(queue.expected_count, queue.pending_count), @@ -1530,6 +1742,9 @@ mod tests { SyncTransportStatusSummary, SyncTransportTargetSummary, }; use radroots_secret_vault::RadrootsSecretBackend; + use radroots_transport::{ + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, + }; use radroots_transport_nostr::{ RadrootsRelayFetchFailure, RadrootsRelayFetchedEvent, RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError, @@ -1537,15 +1752,16 @@ mod tests { use tempfile::tempdir; use super::{ - RelayIngestScope, freshness_for_scope, market_refresh_with_fetcher, pull_with_fetcher, - relay_provenance_relays_for_scope, sdk_push_dry_run_view, sdk_push_view, + RelayIngestScope, freshness_for_scope, inspect_sync, market_refresh_with_fetcher, + pull_with_fetcher, relay_provenance_relays_for_scope, sdk_push_dry_run_view, sdk_push_view, sdk_sync_status_view, }; use crate::cli::global::{FindQueryArgs, RecordLookupArgs}; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, - LocalConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, RpcConfig, - RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + LocalConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, + ReticulumPreviewBehavior, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, + TransportProfileKind, Verbosity, }; const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; @@ -1595,6 +1811,89 @@ mod tests { } #[test] + fn sync_pull_hybrid_reports_profile_targets_and_nostr_fetch_detail() { + let dir = tempdir().expect("tempdir"); + let config = hybrid_config(dir.path(), vec!["wss://relay.example.com".to_owned()]); + crate::runtime::store::init(&config).expect("store init"); + + let view = pull_with_fetcher(&config, fake_fetcher(Vec::new())).expect("sync pull hybrid"); + + assert_eq!(view.state, "ready"); + assert_eq!(view.configured_transport_target_count, 2); + assert_eq!(view.configured_transport_targets.len(), 2); + assert_eq!(view.configured_transport_targets[0].transport_kind, "nostr"); + assert_eq!( + view.configured_transport_targets[0].endpoint_uri, + "wss://relay.example.com" + ); + assert_eq!( + view.configured_transport_targets[1].transport_kind, + "reticulum" + ); + assert_eq!(view.transport_statuses.len(), 2); + assert_eq!(view.transport_statuses[0].transport_kind, "nostr"); + assert!(view.transport_statuses[0].fetch_usable); + assert_eq!(view.transport_statuses[1].transport_kind, "reticulum"); + assert_eq!(view.transport_statuses[1].readiness, "preview_unavailable"); + assert!(!view.transport_statuses[1].fetch_usable); + assert_eq!( + view.target_transport_endpoints, + vec!["wss://relay.example.com"] + ); + assert_eq!( + view.attempted_transport_endpoints, + vec!["wss://relay.example.com"] + ); + } + + #[test] + fn sync_inspect_hybrid_uses_profile_target_count() { + let dir = tempdir().expect("tempdir"); + let config = hybrid_config(dir.path(), vec!["wss://relay.example.com".to_owned()]); + crate::runtime::store::init(&config).expect("store init"); + + let snapshot = inspect_sync(&config).expect("inspect sync hybrid"); + + assert_eq!(snapshot.state, "ready"); + assert_eq!(snapshot.configured_transport_target_count, 2); + assert_eq!(snapshot.configured_transport_targets.len(), 2); + assert_eq!(snapshot.transport_statuses.len(), 2); + assert_eq!(snapshot.transport_statuses[1].transport_kind, "reticulum"); + assert_eq!( + snapshot.transport_statuses[1].readiness, + "preview_unavailable" + ); + } + + #[test] + fn sync_pull_reticulum_preview_does_not_report_fetch_success() { + let dir = tempdir().expect("tempdir"); + let config = reticulum_preview_config(dir.path()); + crate::runtime::store::init(&config).expect("store init"); + + let view = pull_with_fetcher(&config, |_, _| { + panic!("reticulum preview sync pull must not run Nostr fetch") + }) + .expect("sync pull reticulum preview"); + + assert_eq!(view.state, "unavailable"); + assert_eq!(view.configured_transport_target_count, 1); + assert_eq!( + view.configured_transport_targets[0].transport_kind, + "reticulum" + ); + assert_eq!(view.transport_statuses[0].readiness, "preview_unavailable"); + assert_eq!(view.fetched_count, None); + assert!(view.target_transport_endpoints.is_empty()); + assert!( + view.reason + .as_deref() + .expect("reticulum preview reason") + .contains("does not expose Nostr relay fetch targets") + ); + } + + #[test] fn sync_status_empty_sdk_store_reports_canonical_source() { let dir = tempdir().expect("tempdir"); let config = sample_config( @@ -1639,6 +1938,78 @@ mod tests { } #[test] + fn sync_status_hybrid_reports_profile_targets_and_statuses() { + let dir = tempdir().expect("tempdir"); + let config = hybrid_config(dir.path(), vec!["wss://relay.example.com".to_owned()]); + let mut receipt = sdk_status_receipt( + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + None, + None, + &["wss://relay.example.com"], + ); + receipt.transport_profile = SyncTransportProfileSummary { + transport_profile_id: "hybrid".to_owned(), + configured_transport_target_count: 2, + configured_transport_targets: vec![ + SyncTransportTargetSummary { + transport_kind: "nostr".to_owned(), + endpoint_uri: "wss://relay.example.com".to_owned(), + endpoint_fingerprint: "0".repeat(64), + }, + SyncTransportTargetSummary { + transport_kind: "reticulum".to_owned(), + endpoint_uri: RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI.to_owned(), + endpoint_fingerprint: "1".repeat(64), + }, + ], + transport_statuses: vec![ + SyncTransportStatusSummary { + transport_kind: "nostr".to_owned(), + profile_id: Some("hybrid".to_owned()), + endpoint_uri: None, + implementation_state: "available".to_owned(), + readiness: "ready".to_owned(), + publish_usable: true, + fetch_usable: true, + redacted_message: None, + }, + SyncTransportStatusSummary { + transport_kind: "reticulum".to_owned(), + profile_id: Some("hybrid".to_owned()), + endpoint_uri: Some(RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI.to_owned()), + implementation_state: "preview_unavailable".to_owned(), + readiness: "preview_unavailable".to_owned(), + publish_usable: false, + fetch_usable: false, + redacted_message: Some(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE.to_owned()), + }, + ], + }; + + let view = sdk_sync_status_view(&config, receipt); + + assert_eq!(view.configured_transport_target_count, 2); + assert_eq!(view.configured_transport_targets.len(), 2); + assert_eq!(view.configured_transport_targets[0].transport_kind, "nostr"); + assert_eq!( + view.configured_transport_targets[1].transport_kind, + "reticulum" + ); + assert_eq!(view.transport_statuses.len(), 2); + assert_eq!(view.transport_statuses[1].readiness, "preview_unavailable"); + assert!(!view.transport_statuses[1].fetch_usable); + } + + #[test] fn sync_status_reports_sdk_pending_retryable_and_terminal_outbox_counts() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path(), vec!["wss://relay.example.com".to_owned()]); @@ -2500,4 +2871,18 @@ mod tests { capability_bindings: Vec::new(), } } + + fn hybrid_config(root: &Path, relays: Vec<String>) -> RuntimeConfig { + let mut config = sample_config(root, relays); + config.transport.profile = TransportProfileKind::Hybrid; + config.transport.reticulum_preview_behavior = ReticulumPreviewBehavior::DeferDeliveryPlans; + config + } + + fn reticulum_preview_config(root: &Path) -> RuntimeConfig { + let mut config = sample_config(root, Vec::new()); + config.transport.profile = TransportProfileKind::ReticulumPreview; + config.transport.reticulum_preview_behavior = ReticulumPreviewBehavior::DeferDeliveryPlans; + config + } } diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -3481,6 +3481,8 @@ pub struct SyncActionView { pub replica_db: String, 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, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1580,6 +1580,8 @@ fn sync_transport_status_source_boundary_rejects_retired_relay_shaped_generic_ou for required in [ "fn sdk_sync_status_view", + "fn sync_transport_metadata", + "fn sync_configured_transport_targets", "fn sdk_push_action_view", "fn sdk_transport_targets", "fn sdk_push_target_transport_endpoints",