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:
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(