cli

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

commit 94130a000a6bab2d37de918f719df0c32f802c56
parent bd6661559deb7f88a647ecd308550fa68d0b4867
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 21:18:44 +0000

transport: expose truthful outbox status

- render preview unavailable deferred and publishing counts in terminal output
- add read-only storage status for delivery-unusable profiles
- seed real outbox states in focused terminal coverage
- keep mutating outbox push behavior fail-closed

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Msrc/out/terminal/renderers/runtime.rs | 13+++++++++++++
Msrc/runtime/sdk.rs | 20++++++++++++++++++++
Msrc/runtime/transport.rs | 16+++++++++++++---
Mtests/target_cli.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
6 files changed, 243 insertions(+), 6 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3629,6 +3629,7 @@ dependencies = [ "radroots_nostr_accounts", "radroots_nostr_connect", "radroots_nostr_signer", + "radroots_outbox", "radroots_protected_store", "radroots_replica_db", "radroots_replica_db_schema", diff --git a/Cargo.toml b/Cargo.toml @@ -65,6 +65,7 @@ zeroize = "1.8" assert_cmd = "2.0" flate2 = "1" nostr = { version = "0.44.2", features = ["nip44"] } +radroots_outbox = { path = "../lib/crates/outbox" } tar = "0.4" tempfile = "3.17" tungstenite = "0.26.2" diff --git a/src/out/terminal/renderers/runtime.rs b/src/out/terminal/renderers/runtime.rs @@ -227,10 +227,23 @@ fn transport_outbox_status_document(envelope: &OutputEnvelope, result: &Value) - common::push_count_field(&mut document, "Terminal", result, &["terminal_count"]); common::push_count_field( &mut document, + "Preview unavailable", + result, + &["preview_unavailable_count"], + ); + common::push_count_field( + &mut document, + "Deferred", + result, + &["deferred_until_implemented_count"], + ); + common::push_count_field( + &mut document, "Ready signed", result, &["ready_signed_count"], ); + common::push_count_field(&mut document, "Publishing", result, &["publishing_count"]); common::push_path_field(&mut document, "Last error", result, &["last_error"]); document } diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -69,6 +69,15 @@ impl CliSdkConfig { }) } + pub fn from_runtime_config_for_storage_status(config: &RuntimeConfig) -> Self { + Self { + storage_root: sdk_storage_root(config), + geonames_cache_root: config.paths.shared_cache_root.clone(), + nostr_relay_url_policy: sdk_nostr_relay_url_policy(config), + transport_profile: TransportProfile::local_only(), + } + } + pub fn builder(&self) -> RadrootsClientBuilder { RadrootsClient::builder() .storage(RadrootsSdkStorageConfig::Directory( @@ -97,6 +106,17 @@ impl CliSdkSession { }) } + pub fn connect_storage_status(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> { + let sdk_config = CliSdkConfig::from_runtime_config_for_storage_status(config); + let runtime = sdk_runtime()?; + let sdk = runtime.block_on(sdk_config.builder().build())?; + Ok(Self { + runtime, + sdk, + config: sdk_config, + }) + } + pub fn connect_memory(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> { let sdk_config = CliSdkConfig::from_runtime_config(config)?; let runtime = sdk_runtime()?; diff --git a/src/runtime/transport.rs b/src/runtime/transport.rs @@ -136,12 +136,22 @@ pub fn status(config: &RuntimeConfig) -> TransportStatusView { pub fn outbox_status( config: &RuntimeConfig, ) -> Result<TransportOutboxStatusView, CliSdkAdapterError> { - let session = CliSdkSession::connect(config)?; + let profile = active_profile_view(config); + let session = if profile.profile_delivery_usable { + CliSdkSession::connect(config)? + } else { + CliSdkSession::connect_storage_status(config)? + }; let receipt = session.block_on(session.sdk().sync().status(SyncStatusRequest::new()))?; + let state = if profile.configured_state == "configured" { + "ready".to_owned() + } else { + profile.configured_state.clone() + }; Ok(TransportOutboxStatusView { - state: "ready".to_owned(), + state, source: "SDK transport outbox".to_owned(), - transport_profile: receipt.transport_profile.transport_profile_id, + transport_profile: profile.profile_id, total_count: receipt.outbox.total_events, pending_count: receipt.outbox.pending_events, retryable_count: receipt.outbox.retryable_events, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -14,10 +14,11 @@ use std::time::{Duration, Instant}; use nostr::nips::nip44::{self, Version}; use nostr::{EventBuilder, Keys, Kind, PublicKey, SecretKey, Tag}; use radroots_events::RadrootsNostrEventPtr; +use radroots_events::draft::RadrootsFrozenEventDraft; use radroots_events::ids::{ RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey, }; -use radroots_events::kinds::{KIND_LISTING, KIND_ORDER_REQUEST}; +use radroots_events::kinds::{KIND_LISTING, KIND_ORDER_REQUEST, KIND_POST}; use radroots_events::order::{RadrootsOrderEconomics, RadrootsOrderItem, RadrootsOrderRequest}; use radroots_events_codec::order::order_request_event_build; use radroots_identity::RadrootsIdentity; @@ -25,16 +26,26 @@ use radroots_local_events::{ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, SourceRuntime, }; -use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event}; +use radroots_nostr::prelude::{ + RadrootsNostrEvent, RadrootsNostrKeys, radroots_nostr_build_event, + radroots_nostr_sign_frozen_draft, +}; use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, }; +use radroots_outbox::{ + RadrootsOutbox, RadrootsOutboxDeliveryPlanInput, RadrootsOutboxReticulumPreviewBehavior, + RadrootsOutboxSignedOperationInput, +}; use radroots_replica_db::{farm, migrations}; use radroots_replica_db_schema::farm::IFarmFields; use radroots_replica_sync::radroots_replica_pending_publish_batch; use radroots_sql_core::SqliteExecutor; -use radroots_transport::RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE; +use radroots_transport::{ + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, + RadrootsTransportKind, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, +}; use serde_json::Value; use serde_json::json; @@ -86,6 +97,105 @@ fn write_proxy_transport_config(sandbox: &RadrootsCliSandbox, body: &str) -> Pat ) } +fn sdk_outbox_path(sandbox: &RadrootsCliSandbox) -> PathBuf { + sandbox + .root() + .join("data/apps/cli/replica/sdk/outbox.sqlite") +} + +fn post_outbox_draft(public_key_hex: &str, content: &str) -> RadrootsFrozenEventDraft { + RadrootsFrozenEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + 1_700_000_000, + vec![vec!["t".to_owned(), "soil".to_owned()]], + content, + public_key_hex, + ) + .expect("post outbox draft") +} + +fn outbox_signer(seed: u8) -> (String, RadrootsNostrKeys) { + let identity = identity_secret(seed); + let public_key_hex = identity.public_key_hex(); + (public_key_hex, identity.into_keys()) +} + +fn transport_target(kind: RadrootsTransportKind, uri: &str) -> RadrootsTransportTarget { + RadrootsTransportTarget::new(kind, uri).expect("transport target") +} + +fn seed_signed_transport_outbox_event( + sandbox: &RadrootsCliSandbox, + content: &str, + transport_profile_id: &str, + target: RadrootsTransportTarget, + behavior: RadrootsOutboxReticulumPreviewBehavior, + claim_for_publish: bool, +) { + let outbox_path = sdk_outbox_path(sandbox); + fs::create_dir_all(outbox_path.parent().expect("outbox parent")).expect("outbox parent"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("outbox fixture runtime"); + runtime.block_on(async { + let outbox = RadrootsOutbox::open_file(&outbox_path) + .await + .expect("open outbox fixture"); + let (public_key_hex, keys) = outbox_signer(201); + let draft = post_outbox_draft(public_key_hex.as_str(), content); + let signed_event = + radroots_nostr_sign_frozen_draft(&keys, &draft).expect("signed outbox event"); + let receipt = outbox + .enqueue_signed_operation(RadrootsOutboxSignedOperationInput::new( + "transport_status_fixture", + draft, + signed_event, + RadrootsOutboxDeliveryPlanInput::new( + transport_profile_id, + 1, + RadrootsTransportSatisfactionPolicy::all_accepted(), + vec![target], + ) + .with_reticulum_preview_behavior(behavior), + true, + 1_700_000_000_007, + 1_700_000_000_000, + )) + .await + .expect("enqueue outbox fixture"); + if claim_for_publish { + outbox + .claim_next_ready_signed_event( + "transport_status_fixture", + "claim-publishing", + 1_700_000_010_000, + 1_700_000_000_100, + ) + .await + .expect("claim outbox fixture") + .expect("claimed outbox fixture"); + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("read claimed event") + .expect("claimed event"); + assert_eq!(event.state.as_str(), "publishing"); + } + }); +} + +fn assert_terminal_field(stdout: &str, label: &str, expected_value: &str) { + assert!( + stdout.lines().any(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with(label) && trimmed.trim_end().ends_with(expected_value) + }), + "missing terminal field `{label}` with value `{expected_value}` in `{stdout}`" + ); +} + struct RelayFetchServer { endpoint: String, handle: JoinHandle<()>, @@ -1530,6 +1640,88 @@ fn transport_status_reticulum_preview_output_reports_unusable_preview_state() { } #[test] +fn transport_outbox_status_human_output_reports_preview_deferred_and_publishing_counts() { + let sandbox = RadrootsCliSandbox::new(); + seed_signed_transport_outbox_event( + &sandbox, + "preview unavailable", + "reticulum_preview", + transport_target( + RadrootsTransportKind::Reticulum, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, + ), + RadrootsOutboxReticulumPreviewBehavior::RejectDeliveryAttempts, + false, + ); + seed_signed_transport_outbox_event( + &sandbox, + "deferred until implemented", + "reticulum_preview", + transport_target( + RadrootsTransportKind::Reticulum, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, + ), + RadrootsOutboxReticulumPreviewBehavior::DeferDeliveryPlans, + false, + ); + seed_signed_transport_outbox_event( + &sandbox, + "publishing", + "nostr", + transport_target(RadrootsTransportKind::Nostr, ORDERABLE_LISTING_RELAY), + RadrootsOutboxReticulumPreviewBehavior::RejectDeliveryAttempts, + true, + ); + + let output = sandbox + .command() + .args(["transport", "outbox", "status"]) + .output() + .expect("run transport outbox status"); + assert!( + output.status.success(), + "stderr `{}` stdout `{}`", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + let stdout = String::from_utf8(output.stdout).expect("terminal stdout"); + + assert!(stdout.contains("Transport Outbox"), "{stdout}"); + assert_terminal_field(&stdout, "Preview unavailable", "1"); + assert_terminal_field(&stdout, "Deferred", "1"); + assert_terminal_field(&stdout, "Publishing", "1"); + assert_terminal_field(&stdout, "Ready signed", "0"); +} + +#[test] +fn transport_outbox_status_read_only_succeeds_for_empty_nostr_profile() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.write_app_config( + "[transport]\nprofile = \"nostr\"\n\n[transport.nostr]\nrelay_urls = []\n", + ); + + let output = sandbox + .command() + .args(["transport", "outbox", "status"]) + .output() + .expect("run transport outbox status"); + assert!( + output.status.success(), + "stderr `{}` stdout `{}`", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + let stdout = String::from_utf8(output.stdout).expect("terminal stdout"); + + assert_terminal_field(&stdout, "State", "unconfigured"); + assert_terminal_field(&stdout, "Profile", "nostr"); + assert_terminal_field(&stdout, "Total", "0"); + assert_terminal_field(&stdout, "Preview unavailable", "0"); + assert_terminal_field(&stdout, "Deferred", "0"); + assert_terminal_field(&stdout, "Publishing", "0"); +} + +#[test] fn config_get_reticulum_preview_transport_exec_readiness_uses_shared_unavailable_copy() { let sandbox = RadrootsCliSandbox::new(); sandbox.write_app_config(