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:
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);