app

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

commit 0fe5cd201d4ee99006b6098bd7934cc046b08d6b
parent 33b8a09bf68eba04e4b422e7536e89ae1a0d8a08
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:29:40 +0000

ffi: define stable structured wire errors

- replace debug-formatted codes with explicit wire enums
- classify failures by category and retryability
- attach typed recovery actions and correlation fields
- preserve sanitized messages across native and Kotlin boundaries

Diffstat:
Mcore/crates/ffi/src/commands.rs | 39+++++++++++++++++++++++++++++++++------
Mcore/crates/ffi/src/dto.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcore/crates/ffi/src/lib.rs | 1+
Mcore/crates/ffi/src/observer.rs | 6+++++-
4 files changed, 178 insertions(+), 10 deletions(-)

diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs @@ -14,7 +14,10 @@ use radroots_studio_application::{ use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; use radroots_studio_storage::{OsKeyringSecretStore, RuntimeActorHandle}; -use crate::{AccountDto, AppSnapshotDto}; +use crate::{ + AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, + dto::error_policy, +}; const DATABASE_QUALIFIER: &str = "org"; const DATABASE_ORGANIZATION: &str = "radroots"; @@ -56,7 +59,14 @@ pub fn compatibility_descriptor() -> CompatibilityDescriptor { #[derive(Debug, uniffi::Error)] pub enum StudioError { - Failure { code: String, safe_message: String }, + Failure { + code: WireErrorCode, + category: WireErrorCategory, + retryable: bool, + recovery_action: WireRecoveryAction, + correlation_id: Option<String>, + safe_message: String, + }, } impl Display for StudioError { @@ -71,8 +81,13 @@ impl std::error::Error for StudioError {} impl From<SafeError> for StudioError { fn from(error: SafeError) -> Self { + let (category, retryable, recovery_action) = error_policy(error.code()); Self::Failure { - code: format!("{:?}", error.code()), + code: error.code().into(), + category, + retryable, + recovery_action, + correlation_id: None, safe_message: error.message().as_str().to_owned(), } } @@ -399,21 +414,33 @@ pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { fn path_unavailable() -> StudioError { StudioError::Failure { - code: "StorageUnavailable".to_owned(), + code: WireErrorCode::StorageUnavailable, + category: WireErrorCategory::Storage, + retryable: true, + recovery_action: WireRecoveryAction::RestartApplication, + correlation_id: None, safe_message: "The application data directory is unavailable.".to_owned(), } } fn confirmation_expired() -> StudioError { StudioError::Failure { - code: "InvalidApplicationState".to_owned(), + code: WireErrorCode::InvalidApplicationState, + category: WireErrorCategory::Lifecycle, + retryable: false, + recovery_action: WireRecoveryAction::None, + correlation_id: None, safe_message: "The account removal confirmation is no longer valid.".to_owned(), } } fn compatibility_mismatch() -> StudioError { StudioError::Failure { - code: "CompatibilityMismatch".to_owned(), + code: WireErrorCode::CompatibilityMismatch, + category: WireErrorCategory::Compatibility, + retryable: false, + recovery_action: WireRecoveryAction::UpdateApplication, + correlation_id: None, safe_message: "The application and native runtime are incompatible.".to_owned(), } } diff --git a/core/crates/ffi/src/dto.rs b/core/crates/ffi/src/dto.rs @@ -2,11 +2,61 @@ use radroots_studio_application::{ ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState, SessionState, }; -use radroots_studio_domain::{AccountSummary, BindingAvailability, ProfileMetadata, SafeError}; +use radroots_studio_domain::{ + AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum WireErrorCode { + InvalidPublicKey, + InvalidSecretKey, + InvalidAccountMetadata, + InvalidProfileMetadata, + InvalidApplicationState, + AccountAlreadyExists, + AccountNotFound, + KeyringUnavailable, + CredentialMissing, + StorageUnavailable, + StorageCorrupt, + PendingOperationRecoveryRequired, + InvalidRelayConfiguration, + RelayConnectionFailed, + ProfileRefreshFailed, + ObserverRegistrationFailed, + NativeLibraryLoadFailed, + CompatibilityMismatch, + Internal, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum WireErrorCategory { + Input, + Conflict, + Credential, + Storage, + Network, + Lifecycle, + Compatibility, + Internal, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum WireRecoveryAction { + None, + Retry, + RepairCredential, + CheckConfiguration, + RestartApplication, + UpdateApplication, +} #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] pub struct SafeErrorDto { - pub code: String, + pub code: WireErrorCode, + pub category: WireErrorCategory, + pub retryable: bool, + pub recovery_action: WireRecoveryAction, pub message: String, } @@ -182,13 +232,99 @@ impl From<&ProfileMetadata> for ProfileDto { impl From<SafeError> for SafeErrorDto { fn from(error: SafeError) -> Self { + let (category, retryable, recovery_action) = error_policy(error.code()); Self { - code: format!("{:?}", error.code()), + code: error.code().into(), + category, + retryable, + recovery_action, message: error.message().as_str().to_owned(), } } } +impl From<SafeErrorCode> for WireErrorCode { + fn from(code: SafeErrorCode) -> Self { + match code { + SafeErrorCode::InvalidPublicKey => Self::InvalidPublicKey, + SafeErrorCode::InvalidSecretKey => Self::InvalidSecretKey, + SafeErrorCode::InvalidAccountMetadata => Self::InvalidAccountMetadata, + SafeErrorCode::InvalidProfileMetadata => Self::InvalidProfileMetadata, + SafeErrorCode::InvalidApplicationState => Self::InvalidApplicationState, + SafeErrorCode::AccountAlreadyExists => Self::AccountAlreadyExists, + SafeErrorCode::AccountNotFound => Self::AccountNotFound, + SafeErrorCode::KeyringUnavailable => Self::KeyringUnavailable, + SafeErrorCode::CredentialMissing => Self::CredentialMissing, + SafeErrorCode::StorageUnavailable => Self::StorageUnavailable, + SafeErrorCode::StorageCorrupt => Self::StorageCorrupt, + SafeErrorCode::PendingOperationRecoveryRequired => { + Self::PendingOperationRecoveryRequired + } + SafeErrorCode::InvalidRelayConfiguration => Self::InvalidRelayConfiguration, + SafeErrorCode::RelayConnectionFailed => Self::RelayConnectionFailed, + SafeErrorCode::ProfileRefreshFailed => Self::ProfileRefreshFailed, + SafeErrorCode::ObserverRegistrationFailed => Self::ObserverRegistrationFailed, + SafeErrorCode::NativeLibraryLoadFailed => Self::NativeLibraryLoadFailed, + _ => Self::Internal, + } + } +} + +pub(crate) const fn error_policy( + code: SafeErrorCode, +) -> (WireErrorCategory, bool, WireRecoveryAction) { + match code { + SafeErrorCode::InvalidPublicKey + | SafeErrorCode::InvalidSecretKey + | SafeErrorCode::InvalidAccountMetadata + | SafeErrorCode::InvalidProfileMetadata => { + (WireErrorCategory::Input, false, WireRecoveryAction::None) + } + SafeErrorCode::AccountAlreadyExists | SafeErrorCode::AccountNotFound => { + (WireErrorCategory::Conflict, false, WireRecoveryAction::None) + } + SafeErrorCode::KeyringUnavailable => ( + WireErrorCategory::Credential, + true, + WireRecoveryAction::Retry, + ), + SafeErrorCode::CredentialMissing => ( + WireErrorCategory::Credential, + false, + WireRecoveryAction::RepairCredential, + ), + SafeErrorCode::StorageUnavailable => ( + WireErrorCategory::Storage, + true, + WireRecoveryAction::RestartApplication, + ), + SafeErrorCode::StorageCorrupt | SafeErrorCode::PendingOperationRecoveryRequired => ( + WireErrorCategory::Storage, + false, + WireRecoveryAction::RestartApplication, + ), + SafeErrorCode::InvalidRelayConfiguration => ( + WireErrorCategory::Network, + false, + WireRecoveryAction::CheckConfiguration, + ), + SafeErrorCode::RelayConnectionFailed | SafeErrorCode::ProfileRefreshFailed => { + (WireErrorCategory::Network, true, WireRecoveryAction::Retry) + } + SafeErrorCode::InvalidApplicationState | SafeErrorCode::ObserverRegistrationFailed => ( + WireErrorCategory::Lifecycle, + true, + WireRecoveryAction::Retry, + ), + SafeErrorCode::NativeLibraryLoadFailed => ( + WireErrorCategory::Internal, + false, + WireRecoveryAction::RestartApplication, + ), + _ => (WireErrorCategory::Internal, false, WireRecoveryAction::None), + } +} + impl From<BindingAvailability> for KeyAvailabilityDto { fn from(value: BindingAvailability) -> Self { match value { diff --git a/core/crates/ffi/src/lib.rs b/core/crates/ffi/src/lib.rs @@ -8,6 +8,7 @@ pub use commands::{GeneratedAccountDto, RemovalRequest, StudioAppCore, StudioErr pub use dto::{ AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto, ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto, + WireErrorCategory, WireErrorCode, WireRecoveryAction, }; pub use observer::{ObserverSubscription, StudioObserver}; diff --git a/core/crates/ffi/src/observer.rs b/core/crates/ffi/src/observer.rs @@ -116,7 +116,11 @@ impl StudioAppCore { fn closed_error() -> StudioError { StudioError::Failure { - code: "InvalidApplicationState".to_owned(), + code: crate::WireErrorCode::InvalidApplicationState, + category: crate::WireErrorCategory::Lifecycle, + retryable: false, + recovery_action: crate::WireRecoveryAction::None, + correlation_id: None, safe_message: "The application runtime is closed.".to_owned(), } }