app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 8df4791632b9a206d2a28fa4fc6d1bd718c68b01
parent e7324be536ff98cd4c7022c74d506f1cc962ad87
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 14:29:21 -0700

app: surface sdk diagnostics in about status

- add lightweight SDK lifecycle status to the desktop runtime summary
- expose app-owned SDK diagnostics rows for ready, degraded, and lifecycle-busy states
- localize SDK status, integrity, relay, issue, and recovery labels
- cover ready, degraded, busy, and runtime path presentation with focused tests

Diffstat:
Mcrates/desktop/src/app.rs | 1+
Mcrates/desktop/src/runtime.rs | 356+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/desktop/src/window.rs | 372++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/i18n/src/keys.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mi18n/locales/en/messages.json | 48++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 805 insertions(+), 20 deletions(-)

diff --git a/crates/desktop/src/app.rs b/crates/desktop/src/app.rs @@ -269,6 +269,7 @@ mod tests { logged_out_startup: LoggedOutStartupProjection::default(), sync_status: crate::runtime::DesktopAppSyncStatusSummary::default(), startup_issue: startup_issue.map(str::to_owned), + sdk_status: None, } } diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -8,9 +8,10 @@ use std::time::{Duration as StdDuration, SystemTime, UNIX_EPOCH}; use chrono::{DateTime, Duration, Utc}; use radroots_app_core::{ AppBuildIdentity, AppDesktopRuntimePaths, AppRuntimeCapture, AppRuntimeMode, - AppRuntimePathsError, AppRuntimeSnapshot, AppSdkConfig, AppSdkDiagnostics, AppSdkRuntime, - AppSdkRuntimeError, AppSdkRuntimeStatus, AppSharedAccountsPaths, PackDayExportWriteError, - prepare_pack_day_export_bundle_at_data_root, + AppRuntimePathsError, AppRuntimeSnapshot, AppSdkConfig, AppSdkDiagnostics, + AppSdkLifecycleState, AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, AppSdkRuntime, + AppSdkRuntimeError, AppSdkRuntimeIssue, AppSdkRuntimeStatus, AppSdkStoragePaths, + AppSharedAccountsPaths, PackDayExportWriteError, prepare_pack_day_export_bundle_at_data_root, shared_local_events_database_path_from_shared_accounts, write_prepared_pack_day_export_bundle, }; use radroots_app_remote_signer::{ @@ -533,6 +534,7 @@ impl DesktopAppRuntime { } pub fn summary(&self) -> DesktopAppRuntimeSummary { + let sdk_status = self.sdk_status_summary(); let state = self.lock_state(); let sync_status = DesktopAppSyncStatusSummary { account_id: state @@ -564,6 +566,7 @@ impl DesktopAppRuntime { runtime_metadata: state.runtime_metadata.clone(), sync_status, startup_issue: state.startup_issue.clone(), + sdk_status, } } @@ -579,6 +582,12 @@ impl DesktopAppRuntime { .map(AppSdkRuntime::status) } + pub fn sdk_status_summary(&self) -> Option<DesktopAppSdkStatusSummary> { + self.sdk_status() + .as_ref() + .map(DesktopAppSdkStatusSummary::from_status) + } + pub fn wait_for_sdk_startup(&self, timeout: StdDuration) -> Option<AppSdkRuntimeStatus> { self.sdk_runtime .lock() @@ -610,6 +619,32 @@ impl DesktopAppRuntime { runtime.diagnostics().map(Some) } + pub fn sdk_diagnostics_summary(&self) -> Option<DesktopAppSdkDiagnosticsSummary> { + let sdk_runtime = self + .sdk_runtime + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = sdk_runtime.as_ref()?; + let status = runtime.status(); + match runtime.diagnostics() { + Ok(diagnostics) => Some(DesktopAppSdkDiagnosticsSummary { + status: DesktopAppSdkStatusSummary::from_status(&diagnostics.runtime), + state: DesktopAppSdkDiagnosticsState::Ready( + DesktopAppSdkReadyDiagnosticsSummary::from_diagnostics(&diagnostics), + ), + }), + Err(error) => { + let issue = desktop_app_sdk_issue_from_runtime_error(&error); + let mut status = DesktopAppSdkStatusSummary::from_status(&status); + status.last_issue = Some(issue.clone()); + Some(DesktopAppSdkDiagnosticsSummary { + status, + state: DesktopAppSdkDiagnosticsState::Blocked(issue), + }) + } + } + } + pub fn selected_settings_section(&self) -> SettingsSection { self.lock_state() .state_store @@ -1284,6 +1319,52 @@ fn start_desktop_sdk_runtime( AppSdkRuntime::start(AppSdkConfig::from_desktop_paths(paths, nostr_relay_urls)) } +fn sdk_storage_path_pair(paths: Option<&AppSdkStoragePaths>) -> (Option<PathBuf>, Option<PathBuf>) { + paths + .map(|paths| { + ( + Some(paths.event_store_path.clone()), + Some(paths.outbox_path.clone()), + ) + }) + .unwrap_or((None, None)) +} + +fn desktop_app_sdk_issue_from_runtime_error( + error: &AppSdkRuntimeError, +) -> DesktopAppSdkIssueSummary { + match error { + AppSdkRuntimeError::CommandFailed(issue) => DesktopAppSdkIssueSummary::from_issue(issue), + AppSdkRuntimeError::CommandQueueCapacityZero => DesktopAppSdkIssueSummary::runtime( + "sdk_command_queue_capacity_zero", + false, + ["review_runtime_configuration"], + ), + AppSdkRuntimeError::WorkerSpawn(_) => { + DesktopAppSdkIssueSummary::runtime("sdk_worker_spawn_failed", true, ["retry_startup"]) + } + AppSdkRuntimeError::CommandQueueFull => DesktopAppSdkIssueSummary::runtime( + "sdk_command_queue_full", + true, + ["retry_status_refresh"], + ), + AppSdkRuntimeError::CommandQueueClosed => { + DesktopAppSdkIssueSummary::runtime("sdk_command_queue_closed", true, ["retry_startup"]) + } + AppSdkRuntimeError::CommandResponseClosed => DesktopAppSdkIssueSummary::runtime( + "sdk_command_response_closed", + true, + ["retry_status_refresh"], + ), + AppSdkRuntimeError::ShutdownAck => { + DesktopAppSdkIssueSummary::runtime("sdk_shutdown_ack_failed", true, ["retry_startup"]) + } + AppSdkRuntimeError::WorkerJoin => { + DesktopAppSdkIssueSummary::runtime("sdk_worker_join_failed", true, ["retry_startup"]) + } + } +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct DesktopAppSyncStatusSummary { pub account_id: Option<String>, @@ -1305,6 +1386,118 @@ pub struct DesktopAppSyncConflictSummary { } #[derive(Clone, Debug, Eq, PartialEq)] +pub struct DesktopAppSdkStatusSummary { + pub lifecycle_state: AppSdkLifecycleState, + pub projection_lifecycle_state: AppSdkProjectionLifecycleState, + pub projection_lifecycle_reason: Option<String>, + pub storage_root: PathBuf, + pub event_store_path: Option<PathBuf>, + pub outbox_path: Option<PathBuf>, + pub relay_target_count: usize, + pub relay_url_policy: AppSdkRelayUrlPolicy, + pub last_issue: Option<DesktopAppSdkIssueSummary>, +} + +impl DesktopAppSdkStatusSummary { + fn from_status(status: &AppSdkRuntimeStatus) -> Self { + let (event_store_path, outbox_path) = sdk_storage_path_pair(status.storage_paths.as_ref()); + Self { + lifecycle_state: status.state, + projection_lifecycle_state: status.projection_lifecycle.state, + projection_lifecycle_reason: status.projection_lifecycle.reason.clone(), + storage_root: status.storage_root.clone(), + event_store_path, + outbox_path, + relay_target_count: status.relay_urls.len(), + relay_url_policy: status.relay_url_policy, + last_issue: status + .last_issue + .as_ref() + .map(DesktopAppSdkIssueSummary::from_issue), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DesktopAppSdkDiagnosticsSummary { + pub status: DesktopAppSdkStatusSummary, + pub state: DesktopAppSdkDiagnosticsState, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DesktopAppSdkDiagnosticsState { + Ready(DesktopAppSdkReadyDiagnosticsSummary), + Blocked(DesktopAppSdkIssueSummary), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DesktopAppSdkReadyDiagnosticsSummary { + pub storage_kind: String, + pub event_store_total_events: i64, + pub outbox_total_events: i64, + pub outbox_pending_events: i64, + pub outbox_failed_terminal_events: i64, + pub integrity_event_store_ok: bool, + pub integrity_outbox_ok: bool, + pub sync_source: String, + pub sync_observed_at_ms: i64, + pub sync_relay_target_count: usize, +} + +impl DesktopAppSdkReadyDiagnosticsSummary { + fn from_diagnostics(diagnostics: &AppSdkDiagnostics) -> Self { + Self { + storage_kind: diagnostics.storage.storage_kind.clone(), + event_store_total_events: diagnostics.storage.event_store.total_events, + outbox_total_events: diagnostics.storage.outbox.total_events, + outbox_pending_events: diagnostics.storage.outbox.pending_events, + outbox_failed_terminal_events: diagnostics.storage.outbox.failed_terminal_events, + integrity_event_store_ok: diagnostics.integrity.event_store_ok, + integrity_outbox_ok: diagnostics.integrity.outbox_ok, + sync_source: diagnostics.sync.source.clone(), + sync_observed_at_ms: diagnostics.sync.observed_at_ms, + sync_relay_target_count: diagnostics.sync.relay_targets.configured_count, + } + } + + pub const fn integrity_ok(&self) -> bool { + self.integrity_event_store_ok && self.integrity_outbox_ok + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DesktopAppSdkIssueSummary { + pub code: String, + pub class: String, + pub retryable: bool, + pub recovery_actions: Vec<String>, +} + +impl DesktopAppSdkIssueSummary { + fn from_issue(issue: &AppSdkRuntimeIssue) -> Self { + Self { + code: issue.code.clone(), + class: issue.class.clone(), + retryable: issue.retryable, + recovery_actions: issue.recovery_actions.clone(), + } + } + + fn runtime( + code: impl Into<String>, + retryable: bool, + recovery_actions: impl IntoIterator<Item = &'static str>, + ) -> Self { + Self { + code: code.into(), + class: "runtime".to_owned(), + retryable, + recovery_actions: recovery_actions.into_iter().map(str::to_owned).collect(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct DesktopAppRuntimeMetadataSummary { pub snapshot: AppRuntimeSnapshot, pub data_root: Option<PathBuf>, @@ -1365,6 +1558,7 @@ pub struct DesktopAppRuntimeSummary { pub runtime_metadata: DesktopAppRuntimeMetadataSummary, pub sync_status: DesktopAppSyncStatusSummary, pub startup_issue: Option<String>, + pub sdk_status: Option<DesktopAppSdkStatusSummary>, } #[derive(Debug, Error)] @@ -9886,8 +10080,8 @@ mod tests { use futures_util::{SinkExt, StreamExt}; use radroots_app_core::{ AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, - AppSdkLifecycleState, AppSharedAccountsPaths, SHARED_ACCOUNTS_STORE_FILE_NAME, - SHARED_IDENTITY_FILE_NAME, + AppSdkLifecycleState, AppSdkProjectionLifecycleState, AppSharedAccountsPaths, + SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITY_FILE_NAME, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, @@ -9981,9 +10175,9 @@ mod tests { use super::{ APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeActivityContextError, DesktopAppRuntimeCommandError, DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeState, - DesktopAppSyncStatusSummary, DesktopRemoteSignerPaths, SYNC_TRANSPORT_UNAVAILABLE_MESSAGE, - SdkDirectRelayAppSyncTransport, TokioRuntimeBuilder, default_sync_transport, - direct_relay_event_source_runtime, farm_sync_payload, is_hex_64, + DesktopAppSdkDiagnosticsState, DesktopAppSyncStatusSummary, DesktopRemoteSignerPaths, + SYNC_TRANSPORT_UNAVAILABLE_MESSAGE, SdkDirectRelayAppSyncTransport, TokioRuntimeBuilder, + default_sync_transport, direct_relay_event_source_runtime, farm_sync_payload, is_hex_64, order_decision_publish_payload_to_sdk_decision, pending_sync_upsert, signed_event_from_local_record, }; @@ -13444,7 +13638,12 @@ mod tests { #[test] fn runtime_bootstrap_starts_sdk_runtime_under_app_data_root() { - let (runtime, paths) = bootstrapped_runtime("sdk_runtime"); + let paths = temp_desktop_runtime_paths("sdk_runtime"); + let runtime = DesktopAppRuntime::bootstrap_from_paths_with_snapshot( + paths.clone(), + vec!["ws://127.0.0.1:8080".to_owned()], + super::default_runtime_snapshot(), + ); let status = runtime .wait_for_sdk_startup(StdDuration::from_secs(5)) .expect("sdk runtime should be present"); @@ -13476,6 +13675,145 @@ mod tests { } #[test] + fn runtime_summary_surfaces_lightweight_sdk_status() { + let paths = temp_desktop_runtime_paths("sdk_summary_status"); + let runtime = DesktopAppRuntime::bootstrap_from_paths_with_snapshot( + paths.clone(), + vec!["ws://127.0.0.1:8080".to_owned()], + super::default_runtime_snapshot(), + ); + runtime + .wait_for_sdk_startup(StdDuration::from_secs(5)) + .expect("sdk runtime should be present"); + + let summary = runtime.summary(); + let sdk_status = summary.sdk_status.expect("sdk status summary"); + + assert_eq!(sdk_status.lifecycle_state, AppSdkLifecycleState::Ready); + assert_eq!( + sdk_status.projection_lifecycle_state, + AppSdkProjectionLifecycleState::Current + ); + assert_eq!(sdk_status.storage_root, paths.app.data.join("sdk")); + assert_eq!( + sdk_status.event_store_path.as_ref(), + Some(&paths.app.data.join("sdk").join("event_store.sqlite")) + ); + assert_eq!(sdk_status.relay_target_count, 1); + assert!( + runtime + .shutdown_sdk_runtime() + .expect("sdk runtime should shut down") + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn runtime_sdk_diagnostics_summary_preserves_degraded_issue_metadata() { + let paths = temp_desktop_runtime_paths("sdk_summary_degraded"); + let runtime = DesktopAppRuntime::bootstrap_from_paths_with_snapshot( + paths.clone(), + vec!["ws://relay.example".to_owned()], + super::default_runtime_snapshot(), + ); + let status = runtime + .wait_for_sdk_startup(StdDuration::from_secs(5)) + .expect("sdk runtime should be present"); + assert_eq!(status.state, AppSdkLifecycleState::Degraded); + + let diagnostics = runtime + .sdk_diagnostics_summary() + .expect("sdk diagnostics summary"); + + assert_eq!( + diagnostics.status.lifecycle_state, + AppSdkLifecycleState::Degraded + ); + match diagnostics.state { + DesktopAppSdkDiagnosticsState::Blocked(issue) => { + assert_eq!(issue.code, "invalid_relay_url"); + assert_eq!(issue.class, "configuration"); + assert!(!issue.retryable); + assert!( + issue + .recovery_actions + .contains(&"configure_relay_targets".to_owned()) + ); + } + unexpected => panic!("unexpected diagnostics state: {unexpected:?}"), + } + assert!( + runtime + .shutdown_sdk_runtime() + .expect("sdk runtime should shut down") + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn runtime_sdk_diagnostics_summary_keeps_lifecycle_busy_visible() { + let paths = temp_desktop_runtime_paths("sdk_summary_busy"); + let runtime = DesktopAppRuntime::bootstrap_from_paths_with_snapshot( + paths.clone(), + vec!["ws://127.0.0.1:8080".to_owned()], + super::default_runtime_snapshot(), + ); + runtime + .wait_for_sdk_startup(StdDuration::from_secs(5)) + .expect("sdk runtime should be present"); + { + let sdk_runtime = runtime.sdk_runtime.lock().expect("sdk runtime lock"); + sdk_runtime + .as_ref() + .expect("sdk runtime") + .begin_projection_rebuild() + .expect("projection rebuild should begin"); + } + + let diagnostics = runtime + .sdk_diagnostics_summary() + .expect("sdk diagnostics summary"); + + assert_eq!( + diagnostics.status.lifecycle_state, + AppSdkLifecycleState::RebuildingProjections + ); + assert_eq!( + diagnostics.status.projection_lifecycle_state, + AppSdkProjectionLifecycleState::Rebuilding + ); + match diagnostics.state { + DesktopAppSdkDiagnosticsState::Blocked(issue) => { + assert_eq!(issue.code, "sdk_lifecycle_busy"); + assert!(issue.retryable); + assert!( + issue + .recovery_actions + .contains(&"wait_for_sdk_lifecycle".to_owned()) + ); + } + unexpected => panic!("unexpected diagnostics state: {unexpected:?}"), + } + { + let sdk_runtime = runtime.sdk_runtime.lock().expect("sdk runtime lock"); + sdk_runtime + .as_ref() + .expect("sdk runtime") + .complete_projection_rebuild() + .expect("projection rebuild should complete"); + } + assert!( + runtime + .shutdown_sdk_runtime() + .expect("sdk runtime should shut down") + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] fn clearing_startup_pending_remote_signer_session_is_idempotent_without_record() { let paths = temp_remote_signer_paths("clear_pending_none"); let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -11,6 +11,9 @@ use gpui_component::{ menu::PopupMenuItem, select::{SearchableVec, Select, SelectDelegate, SelectEvent, SelectState}, }; +use radroots_app_core::{ + AppSdkLifecycleState, AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, +}; use radroots_app_i18n::{AppTextKey, app_text}; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, @@ -99,8 +102,10 @@ use crate::pack_day_print::{ PackDayPrintError, execute_pack_day_batch_print_plan, execute_pack_day_print_plan, }; use crate::runtime::{ - DesktopAppRuntime, DesktopAppRuntimeSummary, DesktopAppSyncConflictSummary, - DesktopAppSyncStatusSummary, + DesktopAppRuntime, DesktopAppRuntimeSummary, DesktopAppSdkDiagnosticsState, + DesktopAppSdkDiagnosticsSummary, DesktopAppSdkIssueSummary, + DesktopAppSdkReadyDiagnosticsSummary, DesktopAppSdkStatusSummary, + DesktopAppSyncConflictSummary, DesktopAppSyncStatusSummary, }; const HOME_WINDOW_MIN_WIDTH_PX: f32 = 1080.0; @@ -8362,7 +8367,8 @@ impl SettingsWindowView { fn about_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement { let runtime = self.runtime.summary(); - let status_rows = about_status_rows(&runtime); + let sdk_diagnostics = self.runtime.sdk_diagnostics_summary(); + let status_rows = about_status_rows(&runtime, sdk_diagnostics.as_ref()); let runtime_rows = about_runtime_rows(&runtime); let manual_refresh_enabled = about_manual_refresh_enabled(&runtime.sync_status); let conflict_cards = runtime @@ -8735,7 +8741,10 @@ fn settings_account_activation_key( } } -fn about_status_rows(runtime: &DesktopAppRuntimeSummary) -> Vec<LabelValueRow> { +fn about_status_rows( + runtime: &DesktopAppRuntimeSummary, + sdk_diagnostics: Option<&DesktopAppSdkDiagnosticsSummary>, +) -> Vec<LabelValueRow> { let mut rows = vec![ LabelValueRow::new( app_shared_text(AppTextKey::MetadataSelectedAccount), @@ -8791,9 +8800,127 @@ fn about_status_rows(runtime: &DesktopAppRuntimeSummary) -> Vec<LabelValueRow> { .unwrap_or_else(|| app_text(AppTextKey::ValueNone)), )); + append_sdk_status_rows(&mut rows, runtime.sdk_status.as_ref(), sdk_diagnostics); + rows } +fn append_sdk_status_rows( + rows: &mut Vec<LabelValueRow>, + sdk_status: Option<&DesktopAppSdkStatusSummary>, + sdk_diagnostics: Option<&DesktopAppSdkDiagnosticsSummary>, +) { + let status = sdk_diagnostics + .map(|diagnostics| &diagnostics.status) + .or(sdk_status); + let Some(status) = status else { + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkLifecycleState), + app_text(AppTextKey::ValueDisabled), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkDiagnosticState), + app_text(AppTextKey::ValueSdkUnavailable), + )); + return; + }; + + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkLifecycleState), + sdk_lifecycle_state_text(status.lifecycle_state), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkProjectionLifecycleState), + sdk_projection_lifecycle_state_text(status.projection_lifecycle_state), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkRelayTargetCount), + status.relay_target_count.to_string(), + )); + + match sdk_diagnostics.map(|diagnostics| &diagnostics.state) { + Some(DesktopAppSdkDiagnosticsState::Ready(ready)) => { + append_ready_sdk_rows(rows, ready); + append_sdk_issue_rows(rows, status.last_issue.as_ref()); + } + Some(DesktopAppSdkDiagnosticsState::Blocked(issue)) => { + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkDiagnosticState), + app_text(AppTextKey::ValueSdkDiagnosticsBlocked), + )); + append_sdk_issue_rows(rows, Some(issue)); + } + None => { + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkDiagnosticState), + app_text(AppTextKey::ValueNone), + )); + append_sdk_issue_rows(rows, status.last_issue.as_ref()); + } + } +} + +fn append_ready_sdk_rows( + rows: &mut Vec<LabelValueRow>, + ready: &DesktopAppSdkReadyDiagnosticsSummary, +) { + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkDiagnosticState), + app_text(AppTextKey::ValueSdkDiagnosticsReady), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkStorageKind), + sdk_storage_kind_text(ready.storage_kind.as_str()), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkEventCount), + ready.event_store_total_events.to_string(), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkOutboxCount), + ready.outbox_total_events.to_string(), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkOutboxPendingCount), + ready.outbox_pending_events.to_string(), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkOutboxFailedCount), + ready.outbox_failed_terminal_events.to_string(), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkIntegrityStatus), + sdk_integrity_status_text(ready.integrity_ok()), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkSyncStatus), + app_text(AppTextKey::ValueSdkDiagnosticsReady), + )); +} + +fn append_sdk_issue_rows(rows: &mut Vec<LabelValueRow>, issue: Option<&DesktopAppSdkIssueSummary>) { + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkLastIssueCode), + issue + .map(|issue| issue.code.clone()) + .unwrap_or_else(|| app_text(AppTextKey::ValueNone)), + )); + if let Some(issue) = issue { + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkLastIssueClass), + issue.class.clone(), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkIssueRetryable), + yes_no_text(issue.retryable), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkRecoveryAction), + sdk_recovery_actions_text(&issue.recovery_actions), + )); + } +} + fn about_conflict_review_body_key(sync_status: &DesktopAppSyncStatusSummary) -> AppTextKey { if !sync_status.is_enabled() { AppTextKey::SettingsAboutConflictReviewUnavailable @@ -8962,6 +9089,24 @@ fn about_runtime_rows(runtime: &DesktopAppRuntimeSummary) -> Vec<LabelValueRow> .storage_key() .to_owned(), )); + if let Some(sdk_status) = runtime.sdk_status.as_ref() { + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkStorageRoot), + sdk_status.storage_root.display().to_string(), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkEventStorePath), + path_or_none(sdk_status.event_store_path.as_ref()), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkOutboxPath), + path_or_none(sdk_status.outbox_path.as_ref()), + )); + rows.push(LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSdkRelayUrlPolicy), + sdk_relay_url_policy_text(sdk_status.relay_url_policy), + )); + } rows } @@ -9003,6 +9148,83 @@ fn path_or_none(path: Option<&PathBuf>) -> String { .unwrap_or_else(|| app_text(AppTextKey::ValueNone)) } +fn sdk_lifecycle_state_text(state: AppSdkLifecycleState) -> String { + app_text(match state { + AppSdkLifecycleState::Starting => AppTextKey::ValueSdkLifecycleStarting, + AppSdkLifecycleState::Ready => AppTextKey::ValueSdkLifecycleReady, + AppSdkLifecycleState::Degraded => AppTextKey::ValueSdkLifecycleDegraded, + AppSdkLifecycleState::Pausing => AppTextKey::ValueSdkLifecyclePausing, + AppSdkLifecycleState::Paused => AppTextKey::ValueSdkLifecyclePaused, + AppSdkLifecycleState::Restoring => AppTextKey::ValueSdkLifecycleRestoring, + AppSdkLifecycleState::RebuildingProjections => { + AppTextKey::ValueSdkLifecycleRebuildingProjections + } + AppSdkLifecycleState::ShuttingDown => AppTextKey::ValueSdkLifecycleShuttingDown, + AppSdkLifecycleState::Stopped => AppTextKey::ValueSdkLifecycleStopped, + }) +} + +fn sdk_projection_lifecycle_state_text(state: AppSdkProjectionLifecycleState) -> String { + app_text(match state { + AppSdkProjectionLifecycleState::Current => AppTextKey::ValueSdkProjectionCurrent, + AppSdkProjectionLifecycleState::Stale => AppTextKey::ValueSdkProjectionStale, + AppSdkProjectionLifecycleState::Rebuilding => AppTextKey::ValueSdkProjectionRebuilding, + }) +} + +fn sdk_relay_url_policy_text(policy: AppSdkRelayUrlPolicy) -> String { + app_text(match policy { + AppSdkRelayUrlPolicy::Public => AppTextKey::ValueSdkRelayPolicyPublic, + AppSdkRelayUrlPolicy::Localhost => AppTextKey::ValueSdkRelayPolicyLocalhost, + }) +} + +fn sdk_storage_kind_text(kind: &str) -> String { + match kind { + "directory" => app_text(AppTextKey::ValueSdkStorageKindDirectory), + _ => app_text(AppTextKey::ValueSdkStorageKindUnknown), + } +} + +fn sdk_integrity_status_text(ok: bool) -> String { + if ok { + app_text(AppTextKey::ValueSdkIntegrityOk) + } else { + app_text(AppTextKey::ValueSdkIntegrityFailed) + } +} + +fn yes_no_text(value: bool) -> String { + if value { + app_text(AppTextKey::ValueYes) + } else { + app_text(AppTextKey::ValueNo) + } +} + +fn sdk_recovery_actions_text(actions: &[String]) -> String { + if actions.is_empty() { + return app_text(AppTextKey::ValueNone); + } + + actions + .iter() + .map(|action| app_text(sdk_recovery_action_key(action))) + .collect::<Vec<_>>() + .join(", ") +} + +fn sdk_recovery_action_key(action: &str) -> AppTextKey { + match action { + "configure_relay_targets" => AppTextKey::ValueSdkRecoveryConfigureRelayTargets, + "retry_startup" => AppTextKey::ValueSdkRecoveryRetryStartup, + "wait_for_sdk_lifecycle" => AppTextKey::ValueSdkRecoveryWaitForLifecycle, + "retry_status_refresh" => AppTextKey::ValueSdkRecoveryRetryStatusRefresh, + "review_runtime_configuration" => AppTextKey::ValueSdkRecoveryReviewRuntimeConfiguration, + _ => AppTextKey::ValueSdkRecoveryReviewStatus, + } +} + fn focus_button<V>(window: &mut Window, id: impl Into<ElementId>, cx: &mut Context<V>) { let focus_handle = window .use_keyed_state(id, cx, |_, cx| cx.focus_handle()) @@ -16944,11 +17166,14 @@ mod tests { trade_payment_display_status_key, trade_revision_status_key, trade_workflow_source_key, }; use crate::runtime::{ - DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeSummary, DesktopAppSyncConflictSummary, - DesktopAppSyncStatusSummary, + DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeSummary, DesktopAppSdkDiagnosticsState, + DesktopAppSdkDiagnosticsSummary, DesktopAppSdkIssueSummary, + DesktopAppSdkReadyDiagnosticsSummary, DesktopAppSdkStatusSummary, + DesktopAppSyncConflictSummary, DesktopAppSyncStatusSummary, }; use radroots_app_core::{ AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, + AppSdkLifecycleState, AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, @@ -19252,11 +19477,14 @@ mod tests { #[test] fn about_status_rows_disable_sync_without_a_selected_account() { - let rows = about_status_rows(&summary( - HomeRoute::SetupRequired, - TodayAgendaProjection::default(), - FarmSetupProjection::default(), - )); + let rows = about_status_rows( + &summary( + HomeRoute::SetupRequired, + TodayAgendaProjection::default(), + FarmSetupProjection::default(), + ), + None, + ); assert!(rows.iter().any(|row| { row.label == app_text(AppTextKey::MetadataSelectedAccount) @@ -19376,6 +19604,103 @@ mod tests { } #[test] + fn about_status_rows_surface_ready_sdk_diagnostics() { + let sdk_status = fixture_sdk_status(AppSdkLifecycleState::Ready); + let sdk_diagnostics = DesktopAppSdkDiagnosticsSummary { + status: sdk_status.clone(), + state: DesktopAppSdkDiagnosticsState::Ready(DesktopAppSdkReadyDiagnosticsSummary { + storage_kind: "directory".to_owned(), + event_store_total_events: 7, + outbox_total_events: 3, + outbox_pending_events: 2, + outbox_failed_terminal_events: 0, + integrity_event_store_ok: true, + integrity_outbox_ok: true, + sync_source: "sdk_canonical_stores".to_owned(), + sync_observed_at_ms: 42, + sync_relay_target_count: 2, + }), + }; + let mut runtime = summary( + HomeRoute::Today, + TodayAgendaProjection::default(), + FarmSetupProjection::default(), + ); + runtime.sdk_status = Some(sdk_status); + + let rows = about_status_rows(&runtime, Some(&sdk_diagnostics)); + + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkLifecycleState) + && row.value == app_text(AppTextKey::ValueSdkLifecycleReady) + })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkDiagnosticState) + && row.value == app_text(AppTextKey::ValueSdkDiagnosticsReady) + })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkStorageKind) + && row.value == app_text(AppTextKey::ValueSdkStorageKindDirectory) + })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkOutboxPendingCount) && row.value == "2" + })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkIntegrityStatus) + && row.value == app_text(AppTextKey::ValueSdkIntegrityOk) + })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkLastIssueCode) + && row.value == app_text(AppTextKey::ValueNone) + })); + } + + #[test] + fn about_status_rows_surface_blocked_sdk_issue_metadata() { + let issue = DesktopAppSdkIssueSummary { + code: "invalid_relay_url".to_owned(), + class: "configuration".to_owned(), + retryable: false, + recovery_actions: vec!["configure_relay_targets".to_owned()], + }; + let mut sdk_status = fixture_sdk_status(AppSdkLifecycleState::Degraded); + sdk_status.last_issue = Some(issue.clone()); + let sdk_diagnostics = DesktopAppSdkDiagnosticsSummary { + status: sdk_status.clone(), + state: DesktopAppSdkDiagnosticsState::Blocked(issue), + }; + let mut runtime = summary( + HomeRoute::Today, + TodayAgendaProjection::default(), + FarmSetupProjection::default(), + ); + runtime.sdk_status = Some(sdk_status); + + let rows = about_status_rows(&runtime, Some(&sdk_diagnostics)); + + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkLifecycleState) + && row.value == app_text(AppTextKey::ValueSdkLifecycleDegraded) + })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkDiagnosticState) + && row.value == app_text(AppTextKey::ValueSdkDiagnosticsBlocked) + })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkLastIssueCode) + && row.value == "invalid_relay_url" + })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkIssueRetryable) + && row.value == app_text(AppTextKey::ValueNo) + })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkRecoveryAction) + && row.value == app_text(AppTextKey::ValueSdkRecoveryConfigureRelayTargets) + })); + } + + #[test] fn about_runtime_rows_append_paths_schema_and_shell_section() { let mut runtime = summary( HomeRoute::Today, @@ -19394,6 +19719,7 @@ mod tests { database_schema_version: Some(7), ..DesktopAppRuntimeMetadataSummary::default() }; + runtime.sdk_status = Some(fixture_sdk_status(AppSdkLifecycleState::Ready)); let rows = about_runtime_rows(&runtime); @@ -19409,6 +19735,14 @@ mod tests { row.label == app_text(AppTextKey::MetadataShellSection) && row.value == ShellSection::Settings(SettingsPanelViewKey::About).storage_key() })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkStorageRoot) + && row.value == "/tmp/radroots/data/apps/app/sdk" + })); + assert!(rows.iter().any(|row| { + row.label == app_text(AppTextKey::MetadataSdkRelayUrlPolicy) + && row.value == app_text(AppTextKey::ValueSdkRelayPolicyLocalhost) + })); } fn summary( @@ -19453,6 +19787,22 @@ mod tests { runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), sync_status: crate::runtime::DesktopAppSyncStatusSummary::default(), startup_issue: None, + sdk_status: None, + } + } + + fn fixture_sdk_status(lifecycle_state: AppSdkLifecycleState) -> DesktopAppSdkStatusSummary { + let storage_root = PathBuf::from("/tmp/radroots/data/apps/app/sdk"); + DesktopAppSdkStatusSummary { + lifecycle_state, + projection_lifecycle_state: AppSdkProjectionLifecycleState::Current, + projection_lifecycle_reason: None, + storage_root: storage_root.clone(), + event_store_path: Some(storage_root.join("event_store.sqlite")), + outbox_path: Some(storage_root.join("outbox.sqlite")), + relay_target_count: 2, + relay_url_policy: AppSdkRelayUrlPolicy::Localhost, + last_issue: None, } } diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -811,9 +811,57 @@ define_app_text_keys! { MetadataSyncConflictDetectedAt => "metadata.sync_conflict_detected_at", MetadataSyncConflictResolution => "metadata.sync_conflict_resolution", MetadataStartupIssue => "metadata.startup_issue", + MetadataSdkLifecycleState => "metadata.sdk_lifecycle_state", + MetadataSdkProjectionLifecycleState => "metadata.sdk_projection_lifecycle_state", + MetadataSdkDiagnosticState => "metadata.sdk_diagnostic_state", + MetadataSdkStorageKind => "metadata.sdk_storage_kind", + MetadataSdkEventCount => "metadata.sdk_event_count", + MetadataSdkOutboxCount => "metadata.sdk_outbox_count", + MetadataSdkOutboxPendingCount => "metadata.sdk_outbox_pending_count", + MetadataSdkOutboxFailedCount => "metadata.sdk_outbox_failed_count", + MetadataSdkIntegrityStatus => "metadata.sdk_integrity_status", + MetadataSdkSyncStatus => "metadata.sdk_sync_status", + MetadataSdkRelayTargetCount => "metadata.sdk_relay_target_count", + MetadataSdkLastIssueCode => "metadata.sdk_last_issue_code", + MetadataSdkLastIssueClass => "metadata.sdk_last_issue_class", + MetadataSdkIssueRetryable => "metadata.sdk_issue_retryable", + MetadataSdkRecoveryAction => "metadata.sdk_recovery_action", + MetadataSdkStorageRoot => "metadata.sdk_storage_root", + MetadataSdkEventStorePath => "metadata.sdk_event_store_path", + MetadataSdkOutboxPath => "metadata.sdk_outbox_path", + MetadataSdkRelayUrlPolicy => "metadata.sdk_relay_url_policy", ValueNone => "value.none", + ValueYes => "value.yes", + ValueNo => "value.no", ValueEnabled => "value.enabled", ValueDisabled => "value.disabled", + ValueSdkUnavailable => "value.sdk.unavailable", + ValueSdkDiagnosticsReady => "value.sdk.diagnostics.ready", + ValueSdkDiagnosticsBlocked => "value.sdk.diagnostics.blocked", + ValueSdkLifecycleStarting => "value.sdk.lifecycle.starting", + ValueSdkLifecycleReady => "value.sdk.lifecycle.ready", + ValueSdkLifecycleDegraded => "value.sdk.lifecycle.degraded", + ValueSdkLifecyclePausing => "value.sdk.lifecycle.pausing", + ValueSdkLifecyclePaused => "value.sdk.lifecycle.paused", + ValueSdkLifecycleRestoring => "value.sdk.lifecycle.restoring", + ValueSdkLifecycleRebuildingProjections => "value.sdk.lifecycle.rebuilding_projections", + ValueSdkLifecycleShuttingDown => "value.sdk.lifecycle.shutting_down", + ValueSdkLifecycleStopped => "value.sdk.lifecycle.stopped", + ValueSdkProjectionCurrent => "value.sdk.projection.current", + ValueSdkProjectionStale => "value.sdk.projection.stale", + ValueSdkProjectionRebuilding => "value.sdk.projection.rebuilding", + ValueSdkRelayPolicyPublic => "value.sdk.relay_policy.public", + ValueSdkRelayPolicyLocalhost => "value.sdk.relay_policy.localhost", + ValueSdkStorageKindDirectory => "value.sdk.storage_kind.directory", + ValueSdkStorageKindUnknown => "value.sdk.storage_kind.unknown", + ValueSdkIntegrityOk => "value.sdk.integrity.ok", + ValueSdkIntegrityFailed => "value.sdk.integrity.failed", + ValueSdkRecoveryConfigureRelayTargets => "value.sdk.recovery.configure_relay_targets", + ValueSdkRecoveryRetryStartup => "value.sdk.recovery.retry_startup", + ValueSdkRecoveryWaitForLifecycle => "value.sdk.recovery.wait_for_lifecycle", + ValueSdkRecoveryRetryStatusRefresh => "value.sdk.recovery.retry_status_refresh", + ValueSdkRecoveryReviewRuntimeConfiguration => "value.sdk.recovery.review_runtime_configuration", + ValueSdkRecoveryReviewStatus => "value.sdk.recovery.review_status", ValueRuntimeModeDevelopment => "value.runtime_mode.development", ValueRuntimeModeProduction => "value.runtime_mode.production", ValueSyncRunStatusIdle => "value.sync_run_status.idle", diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -790,9 +790,57 @@ "metadata.sync_conflict_detected_at": "detected", "metadata.sync_conflict_resolution": "resolution", "metadata.startup_issue": "startup issue", + "metadata.sdk_lifecycle_state": "SDK lifecycle", + "metadata.sdk_projection_lifecycle_state": "SDK projections", + "metadata.sdk_diagnostic_state": "SDK diagnostics", + "metadata.sdk_storage_kind": "SDK storage", + "metadata.sdk_event_count": "SDK events", + "metadata.sdk_outbox_count": "SDK outbox events", + "metadata.sdk_outbox_pending_count": "SDK outbox pending", + "metadata.sdk_outbox_failed_count": "SDK outbox failed", + "metadata.sdk_integrity_status": "SDK integrity", + "metadata.sdk_sync_status": "SDK sync", + "metadata.sdk_relay_target_count": "SDK relay targets", + "metadata.sdk_last_issue_code": "SDK issue code", + "metadata.sdk_last_issue_class": "SDK issue class", + "metadata.sdk_issue_retryable": "SDK issue retryable", + "metadata.sdk_recovery_action": "SDK recovery", + "metadata.sdk_storage_root": "SDK storage root", + "metadata.sdk_event_store_path": "SDK event store", + "metadata.sdk_outbox_path": "SDK outbox", + "metadata.sdk_relay_url_policy": "SDK relay policy", "value.none": "none", + "value.yes": "yes", + "value.no": "no", "value.enabled": "enabled", "value.disabled": "disabled", + "value.sdk.unavailable": "unavailable", + "value.sdk.diagnostics.ready": "ready", + "value.sdk.diagnostics.blocked": "blocked", + "value.sdk.lifecycle.starting": "starting", + "value.sdk.lifecycle.ready": "ready", + "value.sdk.lifecycle.degraded": "degraded", + "value.sdk.lifecycle.pausing": "pausing", + "value.sdk.lifecycle.paused": "paused", + "value.sdk.lifecycle.restoring": "restoring", + "value.sdk.lifecycle.rebuilding_projections": "rebuilding projections", + "value.sdk.lifecycle.shutting_down": "shutting down", + "value.sdk.lifecycle.stopped": "stopped", + "value.sdk.projection.current": "current", + "value.sdk.projection.stale": "stale", + "value.sdk.projection.rebuilding": "rebuilding", + "value.sdk.relay_policy.public": "public", + "value.sdk.relay_policy.localhost": "localhost", + "value.sdk.storage_kind.directory": "directory", + "value.sdk.storage_kind.unknown": "unknown", + "value.sdk.integrity.ok": "ok", + "value.sdk.integrity.failed": "failed", + "value.sdk.recovery.configure_relay_targets": "Configure relay targets.", + "value.sdk.recovery.retry_startup": "Retry startup.", + "value.sdk.recovery.wait_for_lifecycle": "Wait for the current SDK operation to finish.", + "value.sdk.recovery.retry_status_refresh": "Refresh status again.", + "value.sdk.recovery.review_runtime_configuration": "Review runtime configuration.", + "value.sdk.recovery.review_status": "Review SDK status.", "value.runtime_mode.development": "development", "value.runtime_mode.production": "production", "value.sync_run_status.idle": "idle",