app

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

sdk.rs (88511B)


      1 use std::{
      2     fmt, io,
      3     path::{Path, PathBuf},
      4     sync::{
      5         Arc, Condvar, Mutex, MutexGuard,
      6         atomic::{AtomicBool, Ordering},
      7         mpsc::{self, Receiver, SyncSender, TrySendError},
      8     },
      9     thread::{self, JoinHandle},
     10     time::{Duration, Instant},
     11 };
     12 
     13 use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner};
     14 use radroots_events::{
     15     RadrootsNostrEvent, RadrootsNostrEventPtr,
     16     contract::RadrootsActorRole,
     17     farm::RadrootsFarm,
     18     listing::RadrootsListing,
     19     order::{
     20         RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderRequest,
     21         RadrootsOrderRevisionDecision, RadrootsOrderRevisionProposal,
     22     },
     23 };
     24 use radroots_nostr::prelude::RadrootsNostrKeys;
     25 use radroots_sdk::{
     26     FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmEnqueueReceipt, IntegrityReceipt,
     27     IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND, ListingEnqueuePublishRequest,
     28     ListingEnqueueReceipt, ORDER_CANCELLATION_OPERATION_KIND, ORDER_DECISION_OPERATION_KIND,
     29     ORDER_REVISION_DECISION_OPERATION_KIND, ORDER_REVISION_PROPOSAL_OPERATION_KIND,
     30     ORDER_SUBMIT_OPERATION_KIND, OrderCancellationEnqueueRequest, OrderCancellationReceipt,
     31     OrderDecisionEnqueueRequest, OrderDecisionReceipt, OrderEvidenceIngestRequest,
     32     OrderRequestEvidenceIngestRequest, OrderRevisionDecisionEnqueueRequest,
     33     OrderRevisionDecisionReceipt, OrderRevisionProposalEnqueueRequest,
     34     OrderRevisionProposalReceipt, OrderSubmitEnqueueRequest, OrderSubmitReceipt, RadrootsSdk,
     35     RadrootsSdkError, RadrootsSdkStoragePaths, RestoreReceipt, RestoreRequest,
     36     SdkBackupVerification, SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy, StorageStatusReceipt,
     37     StorageStatusRequest, SyncStatusReceipt, SyncStatusRequest,
     38 };
     39 use radroots_sdk::{SdkMutationState, SdkRelayTargetPolicy};
     40 use serde::Serialize;
     41 use serde_json::{Value, json};
     42 use thiserror::Error;
     43 use tokio::runtime::Builder as TokioRuntimeBuilder;
     44 
     45 use crate::AppDesktopRuntimePaths;
     46 
     47 pub const APP_SDK_STORAGE_DIR_NAME: &str = "sdk";
     48 pub const APP_SDK_DEFAULT_COMMAND_QUEUE_CAPACITY: usize = 32;
     49 
     50 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     51 pub enum AppSdkRelayUrlPolicy {
     52     Public,
     53     Localhost,
     54 }
     55 
     56 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     57 pub enum AppSdkLifecycleState {
     58     Starting,
     59     Ready,
     60     Degraded,
     61     Pausing,
     62     Paused,
     63     Restoring,
     64     RebuildingProjections,
     65     ShuttingDown,
     66     Stopped,
     67 }
     68 
     69 #[derive(Clone, Debug, Eq, PartialEq)]
     70 pub struct AppSdkConfig {
     71     pub storage_root: PathBuf,
     72     pub relay_urls: Vec<String>,
     73     pub relay_url_policy: AppSdkRelayUrlPolicy,
     74     pub command_queue_capacity: usize,
     75 }
     76 
     77 #[derive(Clone, Debug, Eq, PartialEq)]
     78 pub struct AppSdkStoragePaths {
     79     pub event_store_path: PathBuf,
     80     pub outbox_path: PathBuf,
     81 }
     82 
     83 #[derive(Clone, Debug, PartialEq)]
     84 pub struct AppSdkRuntimeIssue {
     85     pub code: String,
     86     pub class: String,
     87     pub retryable: bool,
     88     pub message: String,
     89     pub recovery_actions: Vec<String>,
     90     pub detail_json: Value,
     91 }
     92 
     93 #[derive(Clone, Debug, PartialEq)]
     94 pub struct AppSdkRuntimeStatus {
     95     pub state: AppSdkLifecycleState,
     96     pub storage_root: PathBuf,
     97     pub relay_urls: Vec<String>,
     98     pub relay_url_policy: AppSdkRelayUrlPolicy,
     99     pub storage_paths: Option<AppSdkStoragePaths>,
    100     pub last_issue: Option<AppSdkRuntimeIssue>,
    101     pub projection_lifecycle: AppSdkProjectionLifecycleStatus,
    102 }
    103 
    104 #[derive(Clone, Debug, PartialEq)]
    105 pub struct AppSdkDiagnostics {
    106     pub runtime: AppSdkRuntimeStatus,
    107     pub storage: AppSdkStorageDiagnostics,
    108     pub integrity: AppSdkIntegrityDiagnostics,
    109     pub sync: AppSdkSyncDiagnostics,
    110 }
    111 
    112 #[derive(Clone, Debug, PartialEq)]
    113 pub struct AppSdkStorageDiagnostics {
    114     pub storage_kind: String,
    115     pub paths: Option<AppSdkStoragePaths>,
    116     pub event_store: AppSdkEventStoreDiagnostics,
    117     pub outbox: AppSdkOutboxDiagnostics,
    118 }
    119 
    120 #[derive(Clone, Debug, PartialEq)]
    121 pub struct AppSdkSqliteStoreDiagnostics {
    122     pub schema_version: i64,
    123     pub journal_mode: String,
    124     pub foreign_keys_enabled: bool,
    125     pub busy_timeout_ms: i64,
    126     pub integrity_ok: bool,
    127     pub integrity_result: String,
    128 }
    129 
    130 #[derive(Clone, Debug, PartialEq)]
    131 pub struct AppSdkEventStoreDiagnostics {
    132     pub store: AppSdkSqliteStoreDiagnostics,
    133     pub total_events: i64,
    134     pub projection_eligible_events: i64,
    135     pub relay_observations: i64,
    136     pub last_event_seq: Option<i64>,
    137     pub last_event_updated_at_ms: Option<i64>,
    138 }
    139 
    140 #[derive(Clone, Debug, PartialEq)]
    141 pub struct AppSdkOutboxDiagnostics {
    142     pub store: AppSdkSqliteStoreDiagnostics,
    143     pub total_events: i64,
    144     pub pending_events: i64,
    145     pub retryable_events: i64,
    146     pub terminal_events: i64,
    147     pub failed_terminal_events: i64,
    148     pub ready_signed_events: i64,
    149     pub publishing_events: i64,
    150     pub last_attempt_at_ms: Option<i64>,
    151     pub last_error: Option<String>,
    152 }
    153 
    154 #[derive(Clone, Debug, PartialEq)]
    155 pub struct AppSdkIntegrityDiagnostics {
    156     pub checked_paths: Vec<PathBuf>,
    157     pub event_store_ok: bool,
    158     pub outbox_ok: bool,
    159     pub event_store_result: String,
    160     pub outbox_result: String,
    161 }
    162 
    163 #[derive(Clone, Debug, PartialEq)]
    164 pub struct AppSdkSyncDiagnostics {
    165     pub source: String,
    166     pub observed_at_ms: i64,
    167     pub event_store: AppSdkSyncEventStoreDiagnostics,
    168     pub outbox: AppSdkSyncOutboxDiagnostics,
    169     pub relay_targets: AppSdkSyncRelayTargetDiagnostics,
    170 }
    171 
    172 #[derive(Clone, Debug, PartialEq)]
    173 pub struct AppSdkSyncEventStoreDiagnostics {
    174     pub total_events: i64,
    175     pub projection_eligible_events: i64,
    176     pub relay_observations: i64,
    177     pub last_event_seq: Option<i64>,
    178     pub last_event_updated_at_ms: Option<i64>,
    179 }
    180 
    181 #[derive(Clone, Debug, PartialEq)]
    182 pub struct AppSdkSyncOutboxDiagnostics {
    183     pub total_events: i64,
    184     pub pending_events: i64,
    185     pub retryable_events: i64,
    186     pub terminal_events: i64,
    187     pub failed_terminal_events: i64,
    188     pub ready_signed_events: i64,
    189     pub publishing_events: i64,
    190     pub last_attempt_at_ms: Option<i64>,
    191     pub last_error: Option<String>,
    192 }
    193 
    194 #[derive(Clone, Debug, PartialEq)]
    195 pub struct AppSdkSyncRelayTargetDiagnostics {
    196     pub configured_count: usize,
    197     pub configured_relays: Vec<String>,
    198 }
    199 
    200 #[derive(Clone, Debug, PartialEq)]
    201 pub struct AppSdkRestorePreflightRequest {
    202     pub source: PathBuf,
    203     pub overwrite_existing_sdk_storage: bool,
    204 }
    205 
    206 pub struct AppSdkFarmPublishRequest {
    207     pub actor_account_id: String,
    208     pub actor_pubkey: String,
    209     pub signer_keys: RadrootsNostrKeys,
    210     pub farm: RadrootsFarm,
    211     pub target_relays: Vec<String>,
    212     pub relay_url_policy: AppSdkRelayUrlPolicy,
    213     pub idempotency_key: Option<String>,
    214 }
    215 
    216 pub struct AppSdkListingPublishRequest {
    217     pub actor_account_id: String,
    218     pub actor_pubkey: String,
    219     pub signer_keys: RadrootsNostrKeys,
    220     pub listing: RadrootsListing,
    221     pub target_relays: Vec<String>,
    222     pub relay_url_policy: AppSdkRelayUrlPolicy,
    223     pub idempotency_key: Option<String>,
    224 }
    225 
    226 pub struct AppSdkOrderSubmitRequest {
    227     pub actor_account_id: String,
    228     pub actor_pubkey: String,
    229     pub signer_keys: RadrootsNostrKeys,
    230     pub listing_event: RadrootsNostrEventPtr,
    231     pub order: RadrootsOrderRequest,
    232     pub target_relays: Vec<String>,
    233     pub relay_url_policy: AppSdkRelayUrlPolicy,
    234     pub idempotency_key: Option<String>,
    235 }
    236 
    237 pub struct AppSdkOrderDecisionRequest {
    238     pub actor_account_id: String,
    239     pub actor_pubkey: String,
    240     pub signer_keys: RadrootsNostrKeys,
    241     pub request_event: RadrootsNostrEvent,
    242     pub request_event_ptr: RadrootsNostrEventPtr,
    243     pub decision: RadrootsOrderDecision,
    244     pub target_relays: Vec<String>,
    245     pub relay_url_policy: AppSdkRelayUrlPolicy,
    246     pub idempotency_key: Option<String>,
    247 }
    248 
    249 pub struct AppSdkOrderRevisionProposalRequest {
    250     pub actor_account_id: String,
    251     pub actor_pubkey: String,
    252     pub signer_keys: RadrootsNostrKeys,
    253     pub evidence_events: Vec<RadrootsNostrEvent>,
    254     pub root_event: RadrootsNostrEventPtr,
    255     pub previous_event: RadrootsNostrEventPtr,
    256     pub proposal: RadrootsOrderRevisionProposal,
    257     pub target_relays: Vec<String>,
    258     pub relay_url_policy: AppSdkRelayUrlPolicy,
    259     pub idempotency_key: Option<String>,
    260 }
    261 
    262 pub struct AppSdkOrderRevisionDecisionRequest {
    263     pub actor_account_id: String,
    264     pub actor_pubkey: String,
    265     pub signer_keys: RadrootsNostrKeys,
    266     pub evidence_events: Vec<RadrootsNostrEvent>,
    267     pub root_event: RadrootsNostrEventPtr,
    268     pub previous_event: RadrootsNostrEventPtr,
    269     pub decision: RadrootsOrderRevisionDecision,
    270     pub target_relays: Vec<String>,
    271     pub relay_url_policy: AppSdkRelayUrlPolicy,
    272     pub idempotency_key: Option<String>,
    273 }
    274 
    275 pub struct AppSdkOrderCancellationRequest {
    276     pub actor_account_id: String,
    277     pub actor_pubkey: String,
    278     pub signer_keys: RadrootsNostrKeys,
    279     pub evidence_events: Vec<RadrootsNostrEvent>,
    280     pub root_event: RadrootsNostrEventPtr,
    281     pub previous_event: RadrootsNostrEventPtr,
    282     pub cancellation: RadrootsOrderCancellation,
    283     pub target_relays: Vec<String>,
    284     pub relay_url_policy: AppSdkRelayUrlPolicy,
    285     pub idempotency_key: Option<String>,
    286 }
    287 
    288 #[derive(Clone, Debug, Eq, PartialEq)]
    289 pub struct AppSdkWorkflowReceipt {
    290     pub operation_kind: String,
    291     pub expected_event_id: String,
    292     pub signed_event_id: String,
    293     pub outbox_operation_id: i64,
    294     pub outbox_event_id: i64,
    295     pub state: String,
    296     pub idempotency_digest_prefix: Option<String>,
    297     pub actor_pubkey: String,
    298 }
    299 
    300 #[derive(Clone, Debug, PartialEq)]
    301 pub struct AppSdkRestorePreflightReceipt {
    302     pub source: PathBuf,
    303     pub destination: PathBuf,
    304     pub state: String,
    305     pub destination_paths: Option<AppSdkStoragePaths>,
    306     pub restored_paths: Option<AppSdkStoragePaths>,
    307     pub event_store_path: PathBuf,
    308     pub outbox_path: PathBuf,
    309     pub manifest_path: PathBuf,
    310     pub verification: AppSdkBackupVerificationDiagnostics,
    311     pub source_storage: AppSdkStorageDiagnostics,
    312     pub projection_lifecycle: AppSdkProjectionLifecycleStatus,
    313 }
    314 
    315 #[derive(Clone, Debug, Eq, PartialEq)]
    316 pub struct AppSdkBackupVerificationDiagnostics {
    317     pub event_store_ok: bool,
    318     pub outbox_ok: bool,
    319     pub event_store_events: i64,
    320     pub outbox_events: i64,
    321 }
    322 
    323 #[derive(Clone, Debug, Eq, PartialEq)]
    324 pub struct AppSdkProjectionLifecycleStatus {
    325     pub state: AppSdkProjectionLifecycleState,
    326     pub reason: Option<String>,
    327     pub restore_source: Option<PathBuf>,
    328 }
    329 
    330 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    331 pub enum AppSdkProjectionLifecycleState {
    332     Current,
    333     Stale,
    334     Rebuilding,
    335 }
    336 
    337 #[derive(Debug, Error)]
    338 pub enum AppSdkRuntimeError {
    339     #[error("app sdk command queue capacity must be greater than zero")]
    340     CommandQueueCapacityZero,
    341     #[error("failed to start app sdk worker: {0}")]
    342     WorkerSpawn(#[from] io::Error),
    343     #[error("app sdk command queue is full")]
    344     CommandQueueFull,
    345     #[error("app sdk command queue is closed")]
    346     CommandQueueClosed,
    347     #[error("app sdk command response channel is closed")]
    348     CommandResponseClosed,
    349     #[error("app sdk command failed: {0}")]
    350     CommandFailed(AppSdkRuntimeIssue),
    351     #[error("app sdk shutdown acknowledgement failed")]
    352     ShutdownAck,
    353     #[error("app sdk worker failed to join")]
    354     WorkerJoin,
    355 }
    356 
    357 #[derive(Debug)]
    358 pub struct AppSdkRuntime {
    359     command_sender: Mutex<Option<SyncSender<AppSdkWorkerCommand>>>,
    360     shared: Arc<AppSdkRuntimeShared>,
    361     worker: Mutex<Option<JoinHandle<()>>>,
    362 }
    363 
    364 #[derive(Debug)]
    365 struct AppSdkRuntimeShared {
    366     status: Mutex<AppSdkRuntimeStatus>,
    367     status_changed: Condvar,
    368     shutdown_requested: AtomicBool,
    369 }
    370 
    371 enum AppSdkWorkerCommand {
    372     StorageStatus(mpsc::Sender<Result<AppSdkStorageDiagnostics, AppSdkRuntimeIssue>>),
    373     IntegrityStatus(mpsc::Sender<Result<AppSdkIntegrityDiagnostics, AppSdkRuntimeIssue>>),
    374     SyncStatus(mpsc::Sender<Result<AppSdkSyncDiagnostics, AppSdkRuntimeIssue>>),
    375     Diagnostics(mpsc::Sender<Result<AppSdkDiagnostics, AppSdkRuntimeIssue>>),
    376     RestorePreflight(
    377         AppSdkRestorePreflightRequest,
    378         mpsc::Sender<Result<AppSdkRestorePreflightReceipt, AppSdkRuntimeIssue>>,
    379     ),
    380     EnqueueFarmPublish(
    381         AppSdkFarmPublishRequest,
    382         mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
    383     ),
    384     EnqueueListingPublish(
    385         AppSdkListingPublishRequest,
    386         mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
    387     ),
    388     EnqueueOrderSubmit(
    389         AppSdkOrderSubmitRequest,
    390         mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
    391     ),
    392     EnqueueOrderDecision(
    393         AppSdkOrderDecisionRequest,
    394         mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
    395     ),
    396     EnqueueOrderRevisionProposal(
    397         AppSdkOrderRevisionProposalRequest,
    398         mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
    399     ),
    400     EnqueueOrderRevisionDecision(
    401         AppSdkOrderRevisionDecisionRequest,
    402         mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
    403     ),
    404     EnqueueOrderCancellation(
    405         AppSdkOrderCancellationRequest,
    406         mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
    407     ),
    408     BeginProjectionRebuild(
    409         mpsc::Sender<Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeIssue>>,
    410     ),
    411     CompleteProjectionRebuild(
    412         mpsc::Sender<Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeIssue>>,
    413     ),
    414 }
    415 
    416 impl fmt::Debug for AppSdkWorkerCommand {
    417     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    418         match self {
    419             Self::StorageStatus(_) => formatter.write_str("StorageStatus"),
    420             Self::IntegrityStatus(_) => formatter.write_str("IntegrityStatus"),
    421             Self::SyncStatus(_) => formatter.write_str("SyncStatus"),
    422             Self::Diagnostics(_) => formatter.write_str("Diagnostics"),
    423             Self::RestorePreflight(_, _) => formatter.write_str("RestorePreflight"),
    424             Self::EnqueueFarmPublish(_, _) => formatter.write_str("EnqueueFarmPublish"),
    425             Self::EnqueueListingPublish(_, _) => formatter.write_str("EnqueueListingPublish"),
    426             Self::EnqueueOrderSubmit(_, _) => formatter.write_str("EnqueueOrderSubmit"),
    427             Self::EnqueueOrderDecision(_, _) => formatter.write_str("EnqueueOrderDecision"),
    428             Self::EnqueueOrderRevisionProposal(_, _) => {
    429                 formatter.write_str("EnqueueOrderRevisionProposal")
    430             }
    431             Self::EnqueueOrderRevisionDecision(_, _) => {
    432                 formatter.write_str("EnqueueOrderRevisionDecision")
    433             }
    434             Self::EnqueueOrderCancellation(_, _) => formatter.write_str("EnqueueOrderCancellation"),
    435             Self::BeginProjectionRebuild(_) => formatter.write_str("BeginProjectionRebuild"),
    436             Self::CompleteProjectionRebuild(_) => formatter.write_str("CompleteProjectionRebuild"),
    437         }
    438     }
    439 }
    440 
    441 impl AppSdkConfig {
    442     pub fn from_desktop_paths(paths: &AppDesktopRuntimePaths, relay_urls: Vec<String>) -> Self {
    443         Self::from_app_data_root(paths.app.data.as_path(), relay_urls)
    444     }
    445 
    446     pub fn from_app_data_root(data_root: &Path, relay_urls: Vec<String>) -> Self {
    447         Self {
    448             storage_root: app_sdk_storage_root_from_data_root(data_root),
    449             relay_url_policy: app_sdk_relay_url_policy(relay_urls.as_slice()),
    450             relay_urls,
    451             command_queue_capacity: APP_SDK_DEFAULT_COMMAND_QUEUE_CAPACITY,
    452         }
    453     }
    454 
    455     pub fn with_command_queue_capacity(mut self, capacity: usize) -> Self {
    456         self.command_queue_capacity = capacity;
    457         self
    458     }
    459 }
    460 
    461 impl AppSdkRestorePreflightRequest {
    462     pub fn new(source: impl Into<PathBuf>) -> Self {
    463         Self {
    464             source: source.into(),
    465             overwrite_existing_sdk_storage: false,
    466         }
    467     }
    468 
    469     pub fn with_overwrite_existing_sdk_storage(mut self, overwrite: bool) -> Self {
    470         self.overwrite_existing_sdk_storage = overwrite;
    471         self
    472     }
    473 }
    474 
    475 impl AppSdkProjectionLifecycleStatus {
    476     pub fn current() -> Self {
    477         Self {
    478             state: AppSdkProjectionLifecycleState::Current,
    479             reason: None,
    480             restore_source: None,
    481         }
    482     }
    483 
    484     fn stale(reason: impl Into<String>, restore_source: Option<PathBuf>) -> Self {
    485         Self {
    486             state: AppSdkProjectionLifecycleState::Stale,
    487             reason: Some(reason.into()),
    488             restore_source,
    489         }
    490     }
    491 
    492     fn rebuilding(reason: impl Into<String>, restore_source: Option<PathBuf>) -> Self {
    493         Self {
    494             state: AppSdkProjectionLifecycleState::Rebuilding,
    495             reason: Some(reason.into()),
    496             restore_source,
    497         }
    498     }
    499 }
    500 
    501 impl AppSdkRuntime {
    502     pub fn start(config: AppSdkConfig) -> Result<Self, AppSdkRuntimeError> {
    503         if config.command_queue_capacity == 0 {
    504             return Err(AppSdkRuntimeError::CommandQueueCapacityZero);
    505         }
    506 
    507         let initial_status =
    508             AppSdkRuntimeStatus::from_config(&config, AppSdkLifecycleState::Starting, None, None);
    509         let shared = Arc::new(AppSdkRuntimeShared {
    510             status: Mutex::new(initial_status),
    511             status_changed: Condvar::new(),
    512             shutdown_requested: AtomicBool::new(false),
    513         });
    514         let (command_sender, command_receiver) = mpsc::sync_channel(config.command_queue_capacity);
    515         let worker_shared = Arc::clone(&shared);
    516         let worker = thread::Builder::new()
    517             .name("radroots-app-sdk-runtime".to_owned())
    518             .spawn(move || run_app_sdk_worker(config, worker_shared, command_receiver))?;
    519 
    520         Ok(Self {
    521             command_sender: Mutex::new(Some(command_sender)),
    522             shared,
    523             worker: Mutex::new(Some(worker)),
    524         })
    525     }
    526 
    527     pub fn status(&self) -> AppSdkRuntimeStatus {
    528         lock_status(&self.shared).clone()
    529     }
    530 
    531     pub fn storage_status(&self) -> Result<AppSdkStorageDiagnostics, AppSdkRuntimeError> {
    532         self.run_command(AppSdkWorkerCommand::StorageStatus)
    533     }
    534 
    535     pub fn integrity_status(&self) -> Result<AppSdkIntegrityDiagnostics, AppSdkRuntimeError> {
    536         self.run_command(AppSdkWorkerCommand::IntegrityStatus)
    537     }
    538 
    539     pub fn sync_status(&self) -> Result<AppSdkSyncDiagnostics, AppSdkRuntimeError> {
    540         self.run_command(AppSdkWorkerCommand::SyncStatus)
    541     }
    542 
    543     pub fn diagnostics(&self) -> Result<AppSdkDiagnostics, AppSdkRuntimeError> {
    544         self.run_command(AppSdkWorkerCommand::Diagnostics)
    545     }
    546 
    547     pub fn restore_preflight(
    548         &self,
    549         request: AppSdkRestorePreflightRequest,
    550     ) -> Result<AppSdkRestorePreflightReceipt, AppSdkRuntimeError> {
    551         self.run_command(|response_sender| {
    552             AppSdkWorkerCommand::RestorePreflight(request, response_sender)
    553         })
    554     }
    555 
    556     pub fn enqueue_farm_publish(
    557         &self,
    558         request: AppSdkFarmPublishRequest,
    559     ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
    560         self.run_command(|response_sender| {
    561             AppSdkWorkerCommand::EnqueueFarmPublish(request, response_sender)
    562         })
    563     }
    564 
    565     pub fn enqueue_listing_publish(
    566         &self,
    567         request: AppSdkListingPublishRequest,
    568     ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
    569         self.run_command(|response_sender| {
    570             AppSdkWorkerCommand::EnqueueListingPublish(request, response_sender)
    571         })
    572     }
    573 
    574     pub fn enqueue_order_submit(
    575         &self,
    576         request: AppSdkOrderSubmitRequest,
    577     ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
    578         self.run_command(|response_sender| {
    579             AppSdkWorkerCommand::EnqueueOrderSubmit(request, response_sender)
    580         })
    581     }
    582 
    583     pub fn enqueue_order_decision(
    584         &self,
    585         request: AppSdkOrderDecisionRequest,
    586     ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
    587         self.run_command(|response_sender| {
    588             AppSdkWorkerCommand::EnqueueOrderDecision(request, response_sender)
    589         })
    590     }
    591 
    592     pub fn enqueue_order_revision_proposal(
    593         &self,
    594         request: AppSdkOrderRevisionProposalRequest,
    595     ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
    596         self.run_command(|response_sender| {
    597             AppSdkWorkerCommand::EnqueueOrderRevisionProposal(request, response_sender)
    598         })
    599     }
    600 
    601     pub fn enqueue_order_revision_decision(
    602         &self,
    603         request: AppSdkOrderRevisionDecisionRequest,
    604     ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
    605         self.run_command(|response_sender| {
    606             AppSdkWorkerCommand::EnqueueOrderRevisionDecision(request, response_sender)
    607         })
    608     }
    609 
    610     pub fn enqueue_order_cancellation(
    611         &self,
    612         request: AppSdkOrderCancellationRequest,
    613     ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
    614         self.run_command(|response_sender| {
    615             AppSdkWorkerCommand::EnqueueOrderCancellation(request, response_sender)
    616         })
    617     }
    618 
    619     pub fn begin_projection_rebuild(
    620         &self,
    621     ) -> Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeError> {
    622         self.run_command(AppSdkWorkerCommand::BeginProjectionRebuild)
    623     }
    624 
    625     pub fn complete_projection_rebuild(
    626         &self,
    627     ) -> Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeError> {
    628         self.run_command(AppSdkWorkerCommand::CompleteProjectionRebuild)
    629     }
    630 
    631     pub fn wait_for_startup(&self, timeout: Duration) -> AppSdkRuntimeStatus {
    632         let deadline = Instant::now()
    633             .checked_add(timeout)
    634             .unwrap_or_else(Instant::now);
    635         let mut status = lock_status(&self.shared);
    636         loop {
    637             if !matches!(status.state, AppSdkLifecycleState::Starting) {
    638                 return status.clone();
    639             }
    640             let now = Instant::now();
    641             if now >= deadline {
    642                 return status.clone();
    643             }
    644             let remaining = deadline.saturating_duration_since(now);
    645             let wait_result = self.shared.status_changed.wait_timeout(status, remaining);
    646             let (next_status, timeout_result) = wait_result.unwrap_or_else(|poisoned| {
    647                 let (guard, timeout_result) = poisoned.into_inner();
    648                 (guard, timeout_result)
    649             });
    650             status = next_status;
    651             if timeout_result.timed_out() {
    652                 return status.clone();
    653             }
    654         }
    655     }
    656 
    657     pub fn shutdown(&self) -> Result<(), AppSdkRuntimeError> {
    658         if matches!(self.status().state, AppSdkLifecycleState::Stopped) {
    659             return self.join_worker();
    660         }
    661 
    662         self.shared.shutdown_requested.store(true, Ordering::SeqCst);
    663         transition_status_state(&self.shared, AppSdkLifecycleState::ShuttingDown);
    664         let command_sender = self
    665             .command_sender
    666             .lock()
    667             .unwrap_or_else(|poisoned| poisoned.into_inner())
    668             .take();
    669         drop(command_sender);
    670         self.join_worker()
    671     }
    672 
    673     fn join_worker(&self) -> Result<(), AppSdkRuntimeError> {
    674         let mut worker = self
    675             .worker
    676             .lock()
    677             .unwrap_or_else(|poisoned| poisoned.into_inner());
    678         let Some(worker) = worker.take() else {
    679             return Ok(());
    680         };
    681         worker.join().map_err(|_| AppSdkRuntimeError::WorkerJoin)
    682     }
    683 
    684     fn run_command<T>(
    685         &self,
    686         command: impl FnOnce(mpsc::Sender<Result<T, AppSdkRuntimeIssue>>) -> AppSdkWorkerCommand,
    687     ) -> Result<T, AppSdkRuntimeError> {
    688         let (response_sender, response_receiver) = mpsc::channel();
    689         let command_sender = {
    690             let command_sender = self
    691                 .command_sender
    692                 .lock()
    693                 .unwrap_or_else(|poisoned| poisoned.into_inner());
    694             if self.shared.shutdown_requested.load(Ordering::SeqCst) {
    695                 return Err(AppSdkRuntimeError::CommandQueueClosed);
    696             }
    697             command_sender
    698                 .as_ref()
    699                 .cloned()
    700                 .ok_or(AppSdkRuntimeError::CommandQueueClosed)?
    701         };
    702         match command_sender.try_send(command(response_sender)) {
    703             Ok(()) => {}
    704             Err(TrySendError::Full(_)) => return Err(AppSdkRuntimeError::CommandQueueFull),
    705             Err(TrySendError::Disconnected(_)) => {
    706                 return Err(AppSdkRuntimeError::CommandQueueClosed);
    707             }
    708         }
    709         response_receiver
    710             .recv()
    711             .map_err(|_| AppSdkRuntimeError::CommandResponseClosed)?
    712             .map_err(AppSdkRuntimeError::CommandFailed)
    713     }
    714 }
    715 
    716 impl Drop for AppSdkRuntime {
    717     fn drop(&mut self) {
    718         let _ = self.shutdown();
    719     }
    720 }
    721 
    722 impl From<AppSdkRelayUrlPolicy> for SdkRuntimeRelayUrlPolicy {
    723     fn from(policy: AppSdkRelayUrlPolicy) -> Self {
    724         match policy {
    725             AppSdkRelayUrlPolicy::Public => Self::Public,
    726             AppSdkRelayUrlPolicy::Localhost => Self::Localhost,
    727         }
    728     }
    729 }
    730 
    731 impl From<&RadrootsSdkStoragePaths> for AppSdkStoragePaths {
    732     fn from(paths: &RadrootsSdkStoragePaths) -> Self {
    733         Self {
    734             event_store_path: paths.event_store_path.clone(),
    735             outbox_path: paths.outbox_path.clone(),
    736         }
    737     }
    738 }
    739 
    740 impl AppSdkRuntimeIssue {
    741     fn from_sdk_error(error: &RadrootsSdkError) -> Self {
    742         Self {
    743             code: error.code().to_owned(),
    744             class: sdk_error_class_label(error),
    745             retryable: error.retryable(),
    746             message: error.to_string(),
    747             recovery_actions: error
    748                 .recovery_actions()
    749                 .into_iter()
    750                 .filter_map(|action| serde_json::to_value(action).ok())
    751                 .filter_map(|value| value.as_str().map(str::to_owned))
    752                 .collect(),
    753             detail_json: error.detail_json(),
    754         }
    755     }
    756 
    757     fn runtime_error(code: &'static str, message: String) -> Self {
    758         Self {
    759             code: code.to_owned(),
    760             class: "runtime".to_owned(),
    761             retryable: true,
    762             message: message.clone(),
    763             recovery_actions: vec!["retry_startup".to_owned()],
    764             detail_json: json!({
    765                 "code": code,
    766                 "class": "runtime",
    767                 "retryable": true,
    768                 "message": message,
    769                 "recovery_actions": ["retry_startup"],
    770                 "detail": {}
    771             }),
    772         }
    773     }
    774 
    775     fn lifecycle_blocked(state: AppSdkLifecycleState) -> Self {
    776         Self {
    777             code: "sdk_lifecycle_busy".to_owned(),
    778             class: "runtime".to_owned(),
    779             retryable: true,
    780             message: format!("app sdk runtime is {:?}", state),
    781             recovery_actions: vec!["wait_for_sdk_lifecycle".to_owned()],
    782             detail_json: json!({
    783                 "code": "sdk_lifecycle_busy",
    784                 "class": "runtime",
    785                 "retryable": true,
    786                 "state": format!("{state:?}"),
    787                 "recovery_actions": ["wait_for_sdk_lifecycle"]
    788             }),
    789         }
    790     }
    791 }
    792 
    793 impl fmt::Display for AppSdkRuntimeIssue {
    794     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    795         write!(formatter, "{}: {}", self.code, self.message)
    796     }
    797 }
    798 
    799 impl AppSdkRuntimeStatus {
    800     fn from_config(
    801         config: &AppSdkConfig,
    802         state: AppSdkLifecycleState,
    803         storage_paths: Option<AppSdkStoragePaths>,
    804         last_issue: Option<AppSdkRuntimeIssue>,
    805     ) -> Self {
    806         Self {
    807             state,
    808             storage_root: config.storage_root.clone(),
    809             relay_urls: config.relay_urls.clone(),
    810             relay_url_policy: config.relay_url_policy,
    811             storage_paths,
    812             last_issue,
    813             projection_lifecycle: AppSdkProjectionLifecycleStatus::current(),
    814         }
    815     }
    816 }
    817 
    818 impl From<StorageStatusReceipt> for AppSdkStorageDiagnostics {
    819     fn from(receipt: StorageStatusReceipt) -> Self {
    820         Self {
    821             storage_kind: serialized_label(&receipt.storage),
    822             paths: receipt.paths.as_ref().map(AppSdkStoragePaths::from),
    823             event_store: AppSdkEventStoreDiagnostics {
    824                 store: receipt.event_store.store.into(),
    825                 total_events: receipt.event_store.total_events,
    826                 projection_eligible_events: receipt.event_store.projection_eligible_events,
    827                 relay_observations: receipt.event_store.relay_observations,
    828                 last_event_seq: receipt.event_store.last_event_seq,
    829                 last_event_updated_at_ms: receipt.event_store.last_event_updated_at_ms,
    830             },
    831             outbox: AppSdkOutboxDiagnostics {
    832                 store: receipt.outbox.store.into(),
    833                 total_events: receipt.outbox.total_events,
    834                 pending_events: receipt.outbox.pending_events,
    835                 retryable_events: receipt.outbox.retryable_events,
    836                 terminal_events: receipt.outbox.terminal_events,
    837                 failed_terminal_events: receipt.outbox.failed_terminal_events,
    838                 ready_signed_events: receipt.outbox.ready_signed_events,
    839                 publishing_events: receipt.outbox.publishing_events,
    840                 last_attempt_at_ms: receipt.outbox.last_attempt_at_ms,
    841                 last_error: receipt.outbox.last_error,
    842             },
    843         }
    844     }
    845 }
    846 
    847 impl From<radroots_sdk::SdkSqliteStoreStatus> for AppSdkSqliteStoreDiagnostics {
    848     fn from(status: radroots_sdk::SdkSqliteStoreStatus) -> Self {
    849         Self {
    850             schema_version: status.schema_version,
    851             journal_mode: status.journal_mode,
    852             foreign_keys_enabled: status.foreign_keys_enabled,
    853             busy_timeout_ms: status.busy_timeout_ms,
    854             integrity_ok: status.integrity_ok,
    855             integrity_result: status.integrity_result,
    856         }
    857     }
    858 }
    859 
    860 impl From<IntegrityReceipt> for AppSdkIntegrityDiagnostics {
    861     fn from(receipt: IntegrityReceipt) -> Self {
    862         Self {
    863             checked_paths: receipt.checked_paths,
    864             event_store_ok: receipt.event_store_ok,
    865             outbox_ok: receipt.outbox_ok,
    866             event_store_result: receipt.event_store_result,
    867             outbox_result: receipt.outbox_result,
    868         }
    869     }
    870 }
    871 
    872 impl From<SyncStatusReceipt> for AppSdkSyncDiagnostics {
    873     fn from(receipt: SyncStatusReceipt) -> Self {
    874         Self {
    875             source: serialized_label(&receipt.source),
    876             observed_at_ms: receipt.observed_at_ms,
    877             event_store: AppSdkSyncEventStoreDiagnostics {
    878                 total_events: receipt.event_store.total_events,
    879                 projection_eligible_events: receipt.event_store.projection_eligible_events,
    880                 relay_observations: receipt.event_store.relay_observations,
    881                 last_event_seq: receipt.event_store.last_event_seq,
    882                 last_event_updated_at_ms: receipt.event_store.last_event_updated_at_ms,
    883             },
    884             outbox: AppSdkSyncOutboxDiagnostics {
    885                 total_events: receipt.outbox.total_events,
    886                 pending_events: receipt.outbox.pending_events,
    887                 retryable_events: receipt.outbox.retryable_events,
    888                 terminal_events: receipt.outbox.terminal_events,
    889                 failed_terminal_events: receipt.outbox.failed_terminal_events,
    890                 ready_signed_events: receipt.outbox.ready_signed_events,
    891                 publishing_events: receipt.outbox.publishing_events,
    892                 last_attempt_at_ms: receipt.outbox.last_attempt_at_ms,
    893                 last_error: receipt.outbox.last_error,
    894             },
    895             relay_targets: AppSdkSyncRelayTargetDiagnostics {
    896                 configured_count: receipt.relay_targets.configured_count,
    897                 configured_relays: receipt.relay_targets.configured_relays,
    898             },
    899         }
    900     }
    901 }
    902 
    903 impl From<SdkBackupVerification> for AppSdkBackupVerificationDiagnostics {
    904     fn from(verification: SdkBackupVerification) -> Self {
    905         Self {
    906             event_store_ok: verification.event_store_ok,
    907             outbox_ok: verification.outbox_ok,
    908             event_store_events: verification.event_store_events,
    909             outbox_events: verification.outbox_events,
    910         }
    911     }
    912 }
    913 
    914 impl AppSdkRestorePreflightReceipt {
    915     fn from_restore_receipt(
    916         receipt: RestoreReceipt,
    917         destination: PathBuf,
    918         projection_lifecycle: AppSdkProjectionLifecycleStatus,
    919     ) -> Self {
    920         Self {
    921             source: receipt.source,
    922             destination: receipt.destination.unwrap_or(destination),
    923             state: serialized_label(&receipt.state),
    924             destination_paths: receipt
    925                 .destination_paths
    926                 .as_ref()
    927                 .map(AppSdkStoragePaths::from),
    928             restored_paths: receipt
    929                 .restored_paths
    930                 .as_ref()
    931                 .map(AppSdkStoragePaths::from),
    932             event_store_path: receipt.event_store_path,
    933             outbox_path: receipt.outbox_path,
    934             manifest_path: receipt.manifest_path,
    935             verification: receipt.verification.into(),
    936             source_storage: receipt.manifest.source_status.into(),
    937             projection_lifecycle,
    938         }
    939     }
    940 }
    941 
    942 pub fn app_sdk_storage_root_from_data_root(data_root: &Path) -> PathBuf {
    943     data_root.join(APP_SDK_STORAGE_DIR_NAME)
    944 }
    945 
    946 fn app_sdk_relay_url_policy(relay_urls: &[String]) -> AppSdkRelayUrlPolicy {
    947     if relay_urls
    948         .iter()
    949         .any(|relay_url| relay_url.trim().to_ascii_lowercase().starts_with("ws://"))
    950     {
    951         AppSdkRelayUrlPolicy::Localhost
    952     } else {
    953         AppSdkRelayUrlPolicy::Public
    954     }
    955 }
    956 
    957 fn run_app_sdk_worker(
    958     config: AppSdkConfig,
    959     shared: Arc<AppSdkRuntimeShared>,
    960     command_receiver: Receiver<AppSdkWorkerCommand>,
    961 ) {
    962     let runtime = match TokioRuntimeBuilder::new_current_thread()
    963         .enable_all()
    964         .build()
    965     {
    966         Ok(runtime) => runtime,
    967         Err(error) => {
    968             replace_status(
    969                 &shared,
    970                 AppSdkRuntimeStatus::from_config(
    971                     &config,
    972                     AppSdkLifecycleState::Degraded,
    973                     None,
    974                     Some(AppSdkRuntimeIssue::runtime_error(
    975                         "tokio_runtime_init",
    976                         error.to_string(),
    977                     )),
    978                 ),
    979             );
    980             run_degraded_worker(config, shared, command_receiver);
    981             return;
    982         }
    983     };
    984 
    985     let mut sdk = match runtime.block_on(build_sdk_runtime(&config)) {
    986         Ok(sdk) => {
    987             replace_status(
    988                 &shared,
    989                 AppSdkRuntimeStatus::from_config(
    990                     &config,
    991                     AppSdkLifecycleState::Ready,
    992                     sdk.storage_paths().map(AppSdkStoragePaths::from),
    993                     None,
    994                 ),
    995             );
    996             Some(sdk)
    997         }
    998         Err(error) => {
    999             replace_status(
   1000                 &shared,
   1001                 AppSdkRuntimeStatus::from_config(
   1002                     &config,
   1003                     AppSdkLifecycleState::Degraded,
   1004                     None,
   1005                     Some(AppSdkRuntimeIssue::from_sdk_error(&error)),
   1006                 ),
   1007             );
   1008             None
   1009         }
   1010     };
   1011 
   1012     while let Ok(command) = command_receiver.recv() {
   1013         if shared.shutdown_requested.load(Ordering::SeqCst) {
   1014             break;
   1015         }
   1016 
   1017         match command {
   1018             AppSdkWorkerCommand::StorageStatus(response_sender) => {
   1019                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1020                     Err(issue)
   1021                 } else {
   1022                     match sdk.as_ref() {
   1023                         Some(sdk) => runtime
   1024                             .block_on(sdk.storage_status(StorageStatusRequest::new()))
   1025                             .map(AppSdkStorageDiagnostics::from)
   1026                             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error)),
   1027                         None => Err(runtime_unavailable_issue(&shared)),
   1028                     }
   1029                 };
   1030                 send_worker_result(&shared, response_sender, result);
   1031             }
   1032             AppSdkWorkerCommand::IntegrityStatus(response_sender) => {
   1033                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1034                     Err(issue)
   1035                 } else {
   1036                     match sdk.as_ref() {
   1037                         Some(sdk) => runtime
   1038                             .block_on(sdk.integrity(IntegrityRequest::new()))
   1039                             .map(AppSdkIntegrityDiagnostics::from)
   1040                             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error)),
   1041                         None => Err(runtime_unavailable_issue(&shared)),
   1042                     }
   1043                 };
   1044                 send_worker_result(&shared, response_sender, result);
   1045             }
   1046             AppSdkWorkerCommand::SyncStatus(response_sender) => {
   1047                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1048                     Err(issue)
   1049                 } else {
   1050                     match sdk.as_ref() {
   1051                         Some(sdk) => runtime
   1052                             .block_on(sdk.sync().status(SyncStatusRequest::new()))
   1053                             .map(AppSdkSyncDiagnostics::from)
   1054                             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error)),
   1055                         None => Err(runtime_unavailable_issue(&shared)),
   1056                     }
   1057                 };
   1058                 send_worker_result(&shared, response_sender, result);
   1059             }
   1060             AppSdkWorkerCommand::Diagnostics(response_sender) => {
   1061                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1062                     Err(issue)
   1063                 } else {
   1064                     match sdk.as_ref() {
   1065                         Some(sdk) => {
   1066                             let mut runtime_status = lock_status(&shared).clone();
   1067                             runtime_status.last_issue = None;
   1068                             runtime
   1069                                 .block_on(collect_sdk_diagnostics(sdk, runtime_status))
   1070                                 .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))
   1071                         }
   1072                         None => Err(runtime_unavailable_issue(&shared)),
   1073                     }
   1074                 };
   1075                 send_worker_result(&shared, response_sender, result);
   1076             }
   1077             AppSdkWorkerCommand::RestorePreflight(request, response_sender) => {
   1078                 let result = match sdk.as_ref() {
   1079                     Some(_) => run_restore_preflight(&runtime, &shared, &config, request),
   1080                     None => Err(runtime_unavailable_issue(&shared)),
   1081                 };
   1082                 send_worker_result(&shared, response_sender, result);
   1083             }
   1084             AppSdkWorkerCommand::EnqueueFarmPublish(request, response_sender) => {
   1085                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1086                     Err(issue)
   1087                 } else {
   1088                     match sdk.as_ref() {
   1089                         Some(sdk) => enqueue_farm_publish_with_sdk(&runtime, sdk, request),
   1090                         None => Err(runtime_unavailable_issue(&shared)),
   1091                     }
   1092                 };
   1093                 send_worker_result(&shared, response_sender, result);
   1094             }
   1095             AppSdkWorkerCommand::EnqueueListingPublish(request, response_sender) => {
   1096                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1097                     Err(issue)
   1098                 } else {
   1099                     match sdk.as_ref() {
   1100                         Some(sdk) => enqueue_listing_publish_with_sdk(&runtime, sdk, request),
   1101                         None => Err(runtime_unavailable_issue(&shared)),
   1102                     }
   1103                 };
   1104                 send_worker_result(&shared, response_sender, result);
   1105             }
   1106             AppSdkWorkerCommand::EnqueueOrderSubmit(request, response_sender) => {
   1107                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1108                     Err(issue)
   1109                 } else {
   1110                     match sdk.as_ref() {
   1111                         Some(sdk) => enqueue_order_submit_with_sdk(&runtime, sdk, request),
   1112                         None => Err(runtime_unavailable_issue(&shared)),
   1113                     }
   1114                 };
   1115                 send_worker_result(&shared, response_sender, result);
   1116             }
   1117             AppSdkWorkerCommand::EnqueueOrderDecision(request, response_sender) => {
   1118                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1119                     Err(issue)
   1120                 } else {
   1121                     match sdk.as_ref() {
   1122                         Some(sdk) => enqueue_order_decision_with_sdk(&runtime, sdk, request),
   1123                         None => Err(runtime_unavailable_issue(&shared)),
   1124                     }
   1125                 };
   1126                 send_worker_result(&shared, response_sender, result);
   1127             }
   1128             AppSdkWorkerCommand::EnqueueOrderRevisionProposal(request, response_sender) => {
   1129                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1130                     Err(issue)
   1131                 } else {
   1132                     match sdk.as_ref() {
   1133                         Some(sdk) => {
   1134                             enqueue_order_revision_proposal_with_sdk(&runtime, sdk, request)
   1135                         }
   1136                         None => Err(runtime_unavailable_issue(&shared)),
   1137                     }
   1138                 };
   1139                 send_worker_result(&shared, response_sender, result);
   1140             }
   1141             AppSdkWorkerCommand::EnqueueOrderRevisionDecision(request, response_sender) => {
   1142                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1143                     Err(issue)
   1144                 } else {
   1145                     match sdk.as_ref() {
   1146                         Some(sdk) => {
   1147                             enqueue_order_revision_decision_with_sdk(&runtime, sdk, request)
   1148                         }
   1149                         None => Err(runtime_unavailable_issue(&shared)),
   1150                     }
   1151                 };
   1152                 send_worker_result(&shared, response_sender, result);
   1153             }
   1154             AppSdkWorkerCommand::EnqueueOrderCancellation(request, response_sender) => {
   1155                 let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
   1156                     Err(issue)
   1157                 } else {
   1158                     match sdk.as_ref() {
   1159                         Some(sdk) => enqueue_order_cancellation_with_sdk(&runtime, sdk, request),
   1160                         None => Err(runtime_unavailable_issue(&shared)),
   1161                     }
   1162                 };
   1163                 send_worker_result(&shared, response_sender, result);
   1164             }
   1165             AppSdkWorkerCommand::BeginProjectionRebuild(response_sender) => {
   1166                 let result = match sdk.as_ref() {
   1167                     Some(_) => Ok(begin_projection_rebuild(&shared)),
   1168                     None => Err(runtime_unavailable_issue(&shared)),
   1169                 };
   1170                 send_worker_result(&shared, response_sender, result);
   1171             }
   1172             AppSdkWorkerCommand::CompleteProjectionRebuild(response_sender) => {
   1173                 let result = match sdk.as_ref() {
   1174                     Some(_) => complete_projection_rebuild(&shared),
   1175                     None => Err(runtime_unavailable_issue(&shared)),
   1176                 };
   1177                 send_worker_result(&shared, response_sender, result);
   1178             }
   1179         }
   1180     }
   1181 
   1182     drop(sdk.take());
   1183     transition_status_state(&shared, AppSdkLifecycleState::Stopped);
   1184 }
   1185 
   1186 fn run_degraded_worker(
   1187     config: AppSdkConfig,
   1188     shared: Arc<AppSdkRuntimeShared>,
   1189     command_receiver: Receiver<AppSdkWorkerCommand>,
   1190 ) {
   1191     while let Ok(command) = command_receiver.recv() {
   1192         if shared.shutdown_requested.load(Ordering::SeqCst) {
   1193             break;
   1194         }
   1195 
   1196         match command {
   1197             AppSdkWorkerCommand::StorageStatus(response_sender) => {
   1198                 send_worker_result(
   1199                     &shared,
   1200                     response_sender,
   1201                     Err(runtime_unavailable_issue(&shared)),
   1202                 );
   1203             }
   1204             AppSdkWorkerCommand::IntegrityStatus(response_sender) => {
   1205                 send_worker_result(
   1206                     &shared,
   1207                     response_sender,
   1208                     Err(runtime_unavailable_issue(&shared)),
   1209                 );
   1210             }
   1211             AppSdkWorkerCommand::SyncStatus(response_sender) => {
   1212                 send_worker_result(
   1213                     &shared,
   1214                     response_sender,
   1215                     Err(runtime_unavailable_issue(&shared)),
   1216                 );
   1217             }
   1218             AppSdkWorkerCommand::Diagnostics(response_sender) => {
   1219                 send_worker_result(
   1220                     &shared,
   1221                     response_sender,
   1222                     Err(runtime_unavailable_issue(&shared)),
   1223                 );
   1224             }
   1225             AppSdkWorkerCommand::RestorePreflight(_, response_sender) => {
   1226                 send_worker_result(
   1227                     &shared,
   1228                     response_sender,
   1229                     Err(runtime_unavailable_issue(&shared)),
   1230                 );
   1231             }
   1232             AppSdkWorkerCommand::EnqueueFarmPublish(_, response_sender) => {
   1233                 send_worker_result(
   1234                     &shared,
   1235                     response_sender,
   1236                     Err(runtime_unavailable_issue(&shared)),
   1237                 );
   1238             }
   1239             AppSdkWorkerCommand::EnqueueListingPublish(_, response_sender) => {
   1240                 send_worker_result(
   1241                     &shared,
   1242                     response_sender,
   1243                     Err(runtime_unavailable_issue(&shared)),
   1244                 );
   1245             }
   1246             AppSdkWorkerCommand::EnqueueOrderSubmit(_, response_sender) => {
   1247                 send_worker_result(
   1248                     &shared,
   1249                     response_sender,
   1250                     Err(runtime_unavailable_issue(&shared)),
   1251                 );
   1252             }
   1253             AppSdkWorkerCommand::EnqueueOrderDecision(_, response_sender) => {
   1254                 send_worker_result(
   1255                     &shared,
   1256                     response_sender,
   1257                     Err(runtime_unavailable_issue(&shared)),
   1258                 );
   1259             }
   1260             AppSdkWorkerCommand::EnqueueOrderRevisionProposal(_, response_sender) => {
   1261                 send_worker_result(
   1262                     &shared,
   1263                     response_sender,
   1264                     Err(runtime_unavailable_issue(&shared)),
   1265                 );
   1266             }
   1267             AppSdkWorkerCommand::EnqueueOrderRevisionDecision(_, response_sender) => {
   1268                 send_worker_result(
   1269                     &shared,
   1270                     response_sender,
   1271                     Err(runtime_unavailable_issue(&shared)),
   1272                 );
   1273             }
   1274             AppSdkWorkerCommand::EnqueueOrderCancellation(_, response_sender) => {
   1275                 send_worker_result(
   1276                     &shared,
   1277                     response_sender,
   1278                     Err(runtime_unavailable_issue(&shared)),
   1279                 );
   1280             }
   1281             AppSdkWorkerCommand::BeginProjectionRebuild(response_sender) => {
   1282                 send_worker_result(
   1283                     &shared,
   1284                     response_sender,
   1285                     Err(runtime_unavailable_issue(&shared)),
   1286                 );
   1287             }
   1288             AppSdkWorkerCommand::CompleteProjectionRebuild(response_sender) => {
   1289                 send_worker_result(
   1290                     &shared,
   1291                     response_sender,
   1292                     Err(runtime_unavailable_issue(&shared)),
   1293                 );
   1294             }
   1295         }
   1296     }
   1297 
   1298     let last_issue = lock_status(&shared).last_issue.clone();
   1299     replace_status(
   1300         &shared,
   1301         AppSdkRuntimeStatus::from_config(&config, AppSdkLifecycleState::Stopped, None, last_issue),
   1302     );
   1303 }
   1304 
   1305 async fn build_sdk_runtime(config: &AppSdkConfig) -> Result<RadrootsSdk, RadrootsSdkError> {
   1306     let mut builder = RadrootsSdk::builder()
   1307         .directory_storage(config.storage_root.clone())
   1308         .relay_url_policy(config.relay_url_policy.into());
   1309     for relay_url in &config.relay_urls {
   1310         builder = builder.relay_url(relay_url.clone());
   1311     }
   1312     builder.build().await
   1313 }
   1314 
   1315 fn run_restore_preflight(
   1316     runtime: &tokio::runtime::Runtime,
   1317     shared: &AppSdkRuntimeShared,
   1318     config: &AppSdkConfig,
   1319     request: AppSdkRestorePreflightRequest,
   1320 ) -> Result<AppSdkRestorePreflightReceipt, AppSdkRuntimeIssue> {
   1321     if let Some(issue) = lifecycle_busy_issue(shared) {
   1322         return Err(issue);
   1323     }
   1324     transition_status_state(shared, AppSdkLifecycleState::Pausing);
   1325     transition_status_state(shared, AppSdkLifecycleState::Paused);
   1326     transition_status_state(shared, AppSdkLifecycleState::Restoring);
   1327 
   1328     let restore_request = RestoreRequest::new(request.source.clone())
   1329         .with_destination(config.storage_root.clone())
   1330         .with_overwrite(request.overwrite_existing_sdk_storage)
   1331         .dry_run();
   1332     let result = runtime
   1333         .block_on(RadrootsSdk::restore(restore_request))
   1334         .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))
   1335         .map(|receipt| {
   1336             let projection_lifecycle = mark_projections_stale(
   1337                 shared,
   1338                 "sdk_restore_preflight",
   1339                 Some(request.source.clone()),
   1340             );
   1341             AppSdkRestorePreflightReceipt::from_restore_receipt(
   1342                 receipt,
   1343                 config.storage_root.clone(),
   1344                 projection_lifecycle,
   1345             )
   1346         });
   1347     if result.is_err() {
   1348         transition_status_state(shared, AppSdkLifecycleState::Ready);
   1349     }
   1350     result
   1351 }
   1352 
   1353 async fn collect_sdk_diagnostics(
   1354     sdk: &RadrootsSdk,
   1355     runtime: AppSdkRuntimeStatus,
   1356 ) -> Result<AppSdkDiagnostics, RadrootsSdkError> {
   1357     let storage = sdk.storage_status(StorageStatusRequest::new()).await?;
   1358     let integrity = sdk.integrity(IntegrityRequest::new()).await?;
   1359     let sync = sdk.sync().status(SyncStatusRequest::new()).await?;
   1360     Ok(AppSdkDiagnostics {
   1361         runtime,
   1362         storage: storage.into(),
   1363         integrity: integrity.into(),
   1364         sync: sync.into(),
   1365     })
   1366 }
   1367 
   1368 fn enqueue_farm_publish_with_sdk(
   1369     runtime: &tokio::runtime::Runtime,
   1370     sdk: &RadrootsSdk,
   1371     request: AppSdkFarmPublishRequest,
   1372 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> {
   1373     let actor = sdk_actor_context(
   1374         request.actor_pubkey.as_str(),
   1375         request.actor_account_id.as_str(),
   1376         RadrootsActorRole::Farmer,
   1377     )?;
   1378     let signer = sdk_local_signer(request.signer_keys)?;
   1379     let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?;
   1380     let mut enqueue = FarmEnqueuePublishRequest::new(actor, request.farm, target_relays);
   1381     if let Some(idempotency_key) = request.idempotency_key.as_deref() {
   1382         enqueue = enqueue
   1383             .try_with_idempotency_key(idempotency_key)
   1384             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1385     }
   1386     let receipt = runtime
   1387         .block_on(
   1388             sdk.farms()
   1389                 .enqueue_publish_with_explicit_signer(enqueue, &signer),
   1390         )
   1391         .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1392     Ok(app_sdk_farm_receipt(receipt, request.actor_pubkey))
   1393 }
   1394 
   1395 fn enqueue_listing_publish_with_sdk(
   1396     runtime: &tokio::runtime::Runtime,
   1397     sdk: &RadrootsSdk,
   1398     request: AppSdkListingPublishRequest,
   1399 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> {
   1400     let actor = sdk_actor_context(
   1401         request.actor_pubkey.as_str(),
   1402         request.actor_account_id.as_str(),
   1403         RadrootsActorRole::Seller,
   1404     )?;
   1405     let signer = sdk_local_signer(request.signer_keys)?;
   1406     let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?;
   1407     let mut enqueue = ListingEnqueuePublishRequest::new(actor, request.listing, target_relays);
   1408     if let Some(idempotency_key) = request.idempotency_key.as_deref() {
   1409         enqueue = enqueue
   1410             .try_with_idempotency_key(idempotency_key)
   1411             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1412     }
   1413     let receipt = runtime
   1414         .block_on(
   1415             sdk.listings()
   1416                 .enqueue_publish_with_explicit_signer(enqueue, &signer),
   1417         )
   1418         .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1419     Ok(app_sdk_listing_receipt(receipt, request.actor_pubkey))
   1420 }
   1421 
   1422 fn enqueue_order_submit_with_sdk(
   1423     runtime: &tokio::runtime::Runtime,
   1424     sdk: &RadrootsSdk,
   1425     request: AppSdkOrderSubmitRequest,
   1426 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> {
   1427     let actor = sdk_actor_context(
   1428         request.actor_pubkey.as_str(),
   1429         request.actor_account_id.as_str(),
   1430         RadrootsActorRole::Buyer,
   1431     )?;
   1432     let signer = sdk_local_signer(request.signer_keys)?;
   1433     let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?;
   1434     let mut enqueue =
   1435         OrderSubmitEnqueueRequest::new(actor, request.listing_event, request.order, target_relays);
   1436     if let Some(idempotency_key) = request.idempotency_key.as_deref() {
   1437         enqueue = enqueue
   1438             .try_with_idempotency_key(idempotency_key)
   1439             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1440     }
   1441     let receipt = runtime
   1442         .block_on(
   1443             sdk.orders()
   1444                 .enqueue_submit_with_explicit_signer(enqueue, &signer),
   1445         )
   1446         .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1447     Ok(app_sdk_order_submit_ack(receipt, request.actor_pubkey))
   1448 }
   1449 
   1450 fn enqueue_order_decision_with_sdk(
   1451     runtime: &tokio::runtime::Runtime,
   1452     sdk: &RadrootsSdk,
   1453     request: AppSdkOrderDecisionRequest,
   1454 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> {
   1455     let actor = sdk_actor_context(
   1456         request.actor_pubkey.as_str(),
   1457         request.actor_account_id.as_str(),
   1458         RadrootsActorRole::Seller,
   1459     )?;
   1460     let signer = sdk_local_signer(request.signer_keys)?;
   1461     let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?;
   1462     runtime
   1463         .block_on(
   1464             sdk.orders()
   1465                 .ingest_request_evidence(OrderRequestEvidenceIngestRequest::new(
   1466                     request.request_event,
   1467                 )),
   1468         )
   1469         .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1470     let mut enqueue = OrderDecisionEnqueueRequest::new(
   1471         actor,
   1472         request.request_event_ptr,
   1473         request.decision,
   1474         target_relays,
   1475     );
   1476     if let Some(idempotency_key) = request.idempotency_key.as_deref() {
   1477         enqueue = enqueue
   1478             .try_with_idempotency_key(idempotency_key)
   1479             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1480     }
   1481     let receipt = runtime
   1482         .block_on(
   1483             sdk.orders()
   1484                 .enqueue_decision_with_explicit_signer(enqueue, &signer),
   1485         )
   1486         .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1487     Ok(app_sdk_order_decision_receipt(
   1488         receipt,
   1489         request.actor_pubkey,
   1490     ))
   1491 }
   1492 
   1493 fn enqueue_order_revision_proposal_with_sdk(
   1494     runtime: &tokio::runtime::Runtime,
   1495     sdk: &RadrootsSdk,
   1496     request: AppSdkOrderRevisionProposalRequest,
   1497 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> {
   1498     let actor = sdk_actor_context(
   1499         request.actor_pubkey.as_str(),
   1500         request.actor_account_id.as_str(),
   1501         RadrootsActorRole::Seller,
   1502     )?;
   1503     let signer = sdk_local_signer(request.signer_keys)?;
   1504     let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?;
   1505     ingest_order_evidence_with_sdk(runtime, sdk, request.evidence_events)?;
   1506     let mut enqueue = OrderRevisionProposalEnqueueRequest::new(
   1507         actor,
   1508         request.root_event,
   1509         request.previous_event,
   1510         request.proposal,
   1511         target_relays,
   1512     );
   1513     if let Some(idempotency_key) = request.idempotency_key.as_deref() {
   1514         enqueue = enqueue
   1515             .try_with_idempotency_key(idempotency_key)
   1516             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1517     }
   1518     let receipt = runtime
   1519         .block_on(
   1520             sdk.orders()
   1521                 .enqueue_revision_proposal_with_explicit_signer(enqueue, &signer),
   1522         )
   1523         .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1524     Ok(app_sdk_order_revision_proposal_receipt(
   1525         receipt,
   1526         request.actor_pubkey,
   1527     ))
   1528 }
   1529 
   1530 fn enqueue_order_revision_decision_with_sdk(
   1531     runtime: &tokio::runtime::Runtime,
   1532     sdk: &RadrootsSdk,
   1533     request: AppSdkOrderRevisionDecisionRequest,
   1534 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> {
   1535     let actor = sdk_actor_context(
   1536         request.actor_pubkey.as_str(),
   1537         request.actor_account_id.as_str(),
   1538         RadrootsActorRole::Buyer,
   1539     )?;
   1540     let signer = sdk_local_signer(request.signer_keys)?;
   1541     let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?;
   1542     ingest_order_evidence_with_sdk(runtime, sdk, request.evidence_events)?;
   1543     let mut enqueue = OrderRevisionDecisionEnqueueRequest::new(
   1544         actor,
   1545         request.root_event,
   1546         request.previous_event,
   1547         request.decision,
   1548         target_relays,
   1549     );
   1550     if let Some(idempotency_key) = request.idempotency_key.as_deref() {
   1551         enqueue = enqueue
   1552             .try_with_idempotency_key(idempotency_key)
   1553             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1554     }
   1555     let receipt = runtime
   1556         .block_on(
   1557             sdk.orders()
   1558                 .enqueue_revision_decision_with_explicit_signer(enqueue, &signer),
   1559         )
   1560         .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1561     Ok(app_sdk_order_revision_decision_receipt(
   1562         receipt,
   1563         request.actor_pubkey,
   1564     ))
   1565 }
   1566 
   1567 fn enqueue_order_cancellation_with_sdk(
   1568     runtime: &tokio::runtime::Runtime,
   1569     sdk: &RadrootsSdk,
   1570     request: AppSdkOrderCancellationRequest,
   1571 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> {
   1572     let actor = sdk_actor_context(
   1573         request.actor_pubkey.as_str(),
   1574         request.actor_account_id.as_str(),
   1575         RadrootsActorRole::Buyer,
   1576     )?;
   1577     let signer = sdk_local_signer(request.signer_keys)?;
   1578     let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?;
   1579     ingest_order_evidence_with_sdk(runtime, sdk, request.evidence_events)?;
   1580     let mut enqueue = OrderCancellationEnqueueRequest::new(
   1581         actor,
   1582         request.root_event,
   1583         request.previous_event,
   1584         request.cancellation,
   1585         target_relays,
   1586     );
   1587     if let Some(idempotency_key) = request.idempotency_key.as_deref() {
   1588         enqueue = enqueue
   1589             .try_with_idempotency_key(idempotency_key)
   1590             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1591     }
   1592     let receipt = runtime
   1593         .block_on(
   1594             sdk.orders()
   1595                 .enqueue_cancellation_with_explicit_signer(enqueue, &signer),
   1596         )
   1597         .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1598     Ok(app_sdk_order_cancellation_receipt(
   1599         receipt,
   1600         request.actor_pubkey,
   1601     ))
   1602 }
   1603 
   1604 fn ingest_order_evidence_with_sdk(
   1605     runtime: &tokio::runtime::Runtime,
   1606     sdk: &RadrootsSdk,
   1607     evidence_events: Vec<RadrootsNostrEvent>,
   1608 ) -> Result<(), AppSdkRuntimeIssue> {
   1609     for event in evidence_events {
   1610         runtime
   1611             .block_on(
   1612                 sdk.orders()
   1613                     .ingest_evidence(OrderEvidenceIngestRequest::new(event)),
   1614             )
   1615             .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
   1616     }
   1617     Ok(())
   1618 }
   1619 
   1620 fn sdk_actor_context(
   1621     actor_pubkey: &str,
   1622     actor_account_id: &str,
   1623     role: RadrootsActorRole,
   1624 ) -> Result<RadrootsActorContext, AppSdkRuntimeIssue> {
   1625     RadrootsActorContext::local_account(actor_pubkey, actor_account_id.to_owned(), [role]).map_err(
   1626         |error| AppSdkRuntimeIssue::runtime_error("sdk_actor_context_invalid", error.to_string()),
   1627     )
   1628 }
   1629 
   1630 fn sdk_local_signer(
   1631     keys: RadrootsNostrKeys,
   1632 ) -> Result<RadrootsLocalEventSigner, AppSdkRuntimeIssue> {
   1633     RadrootsLocalEventSigner::new(keys).map_err(|error| {
   1634         AppSdkRuntimeIssue::runtime_error("sdk_signer_init_failed", error.to_string())
   1635     })
   1636 }
   1637 
   1638 fn sdk_relay_targets(
   1639     relays: Vec<String>,
   1640     policy: AppSdkRelayUrlPolicy,
   1641 ) -> Result<SdkRelayTargetPolicy, AppSdkRuntimeIssue> {
   1642     SdkRelayTargetPolicy::try_explicit(relays, policy.into())
   1643         .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))
   1644 }
   1645 
   1646 fn app_sdk_farm_receipt(
   1647     receipt: FarmEnqueueReceipt,
   1648     actor_pubkey: String,
   1649 ) -> AppSdkWorkflowReceipt {
   1650     AppSdkWorkflowReceipt {
   1651         operation_kind: FARM_PUBLISH_OPERATION_KIND.to_owned(),
   1652         expected_event_id: receipt.expected_event_id.as_str().to_owned(),
   1653         signed_event_id: receipt.signed_event_id.as_str().to_owned(),
   1654         outbox_operation_id: receipt.outbox_operation_id,
   1655         outbox_event_id: receipt.outbox_event_id,
   1656         state: sdk_mutation_state_key(receipt.state).to_owned(),
   1657         idempotency_digest_prefix: receipt.idempotency_digest_prefix,
   1658         actor_pubkey,
   1659     }
   1660 }
   1661 
   1662 fn app_sdk_listing_receipt(
   1663     receipt: ListingEnqueueReceipt,
   1664     actor_pubkey: String,
   1665 ) -> AppSdkWorkflowReceipt {
   1666     AppSdkWorkflowReceipt {
   1667         operation_kind: LISTING_PUBLISH_OPERATION_KIND.to_owned(),
   1668         expected_event_id: receipt.expected_event_id.as_str().to_owned(),
   1669         signed_event_id: receipt.signed_event_id.as_str().to_owned(),
   1670         outbox_operation_id: receipt.outbox_operation_id,
   1671         outbox_event_id: receipt.outbox_event_id,
   1672         state: sdk_mutation_state_key(receipt.state).to_owned(),
   1673         idempotency_digest_prefix: receipt.idempotency_digest_prefix,
   1674         actor_pubkey,
   1675     }
   1676 }
   1677 
   1678 fn app_sdk_order_submit_ack(
   1679     receipt: OrderSubmitReceipt,
   1680     actor_pubkey: String,
   1681 ) -> AppSdkWorkflowReceipt {
   1682     AppSdkWorkflowReceipt {
   1683         operation_kind: ORDER_SUBMIT_OPERATION_KIND.to_owned(),
   1684         expected_event_id: receipt.expected_event_id.as_str().to_owned(),
   1685         signed_event_id: receipt.signed_event_id.as_str().to_owned(),
   1686         outbox_operation_id: receipt.outbox_operation_id,
   1687         outbox_event_id: receipt.outbox_event_id,
   1688         state: sdk_mutation_state_key(receipt.state).to_owned(),
   1689         idempotency_digest_prefix: receipt.idempotency_digest_prefix,
   1690         actor_pubkey,
   1691     }
   1692 }
   1693 
   1694 fn app_sdk_order_decision_receipt(
   1695     receipt: OrderDecisionReceipt,
   1696     actor_pubkey: String,
   1697 ) -> AppSdkWorkflowReceipt {
   1698     AppSdkWorkflowReceipt {
   1699         operation_kind: ORDER_DECISION_OPERATION_KIND.to_owned(),
   1700         expected_event_id: receipt.expected_event_id.as_str().to_owned(),
   1701         signed_event_id: receipt.signed_event_id.as_str().to_owned(),
   1702         outbox_operation_id: receipt.outbox_operation_id,
   1703         outbox_event_id: receipt.outbox_event_id,
   1704         state: sdk_mutation_state_key(receipt.state).to_owned(),
   1705         idempotency_digest_prefix: receipt.idempotency_digest_prefix,
   1706         actor_pubkey,
   1707     }
   1708 }
   1709 
   1710 fn app_sdk_order_revision_proposal_receipt(
   1711     receipt: OrderRevisionProposalReceipt,
   1712     actor_pubkey: String,
   1713 ) -> AppSdkWorkflowReceipt {
   1714     AppSdkWorkflowReceipt {
   1715         operation_kind: ORDER_REVISION_PROPOSAL_OPERATION_KIND.to_owned(),
   1716         expected_event_id: receipt.expected_event_id.as_str().to_owned(),
   1717         signed_event_id: receipt.signed_event_id.as_str().to_owned(),
   1718         outbox_operation_id: receipt.outbox_operation_id,
   1719         outbox_event_id: receipt.outbox_event_id,
   1720         state: sdk_mutation_state_key(receipt.state).to_owned(),
   1721         idempotency_digest_prefix: receipt.idempotency_digest_prefix,
   1722         actor_pubkey,
   1723     }
   1724 }
   1725 
   1726 fn app_sdk_order_revision_decision_receipt(
   1727     receipt: OrderRevisionDecisionReceipt,
   1728     actor_pubkey: String,
   1729 ) -> AppSdkWorkflowReceipt {
   1730     AppSdkWorkflowReceipt {
   1731         operation_kind: ORDER_REVISION_DECISION_OPERATION_KIND.to_owned(),
   1732         expected_event_id: receipt.expected_event_id.as_str().to_owned(),
   1733         signed_event_id: receipt.signed_event_id.as_str().to_owned(),
   1734         outbox_operation_id: receipt.outbox_operation_id,
   1735         outbox_event_id: receipt.outbox_event_id,
   1736         state: sdk_mutation_state_key(receipt.state).to_owned(),
   1737         idempotency_digest_prefix: receipt.idempotency_digest_prefix,
   1738         actor_pubkey,
   1739     }
   1740 }
   1741 
   1742 fn app_sdk_order_cancellation_receipt(
   1743     receipt: OrderCancellationReceipt,
   1744     actor_pubkey: String,
   1745 ) -> AppSdkWorkflowReceipt {
   1746     AppSdkWorkflowReceipt {
   1747         operation_kind: ORDER_CANCELLATION_OPERATION_KIND.to_owned(),
   1748         expected_event_id: receipt.expected_event_id.as_str().to_owned(),
   1749         signed_event_id: receipt.signed_event_id.as_str().to_owned(),
   1750         outbox_operation_id: receipt.outbox_operation_id,
   1751         outbox_event_id: receipt.outbox_event_id,
   1752         state: sdk_mutation_state_key(receipt.state).to_owned(),
   1753         idempotency_digest_prefix: receipt.idempotency_digest_prefix,
   1754         actor_pubkey,
   1755     }
   1756 }
   1757 
   1758 fn sdk_mutation_state_key(state: SdkMutationState) -> &'static str {
   1759     match state {
   1760         SdkMutationState::StoredAndQueued => "enqueued",
   1761         SdkMutationState::AlreadyQueued => "already_queued",
   1762         _ => "unknown",
   1763     }
   1764 }
   1765 
   1766 fn send_worker_result<T>(
   1767     shared: &AppSdkRuntimeShared,
   1768     response_sender: mpsc::Sender<Result<T, AppSdkRuntimeIssue>>,
   1769     result: Result<T, AppSdkRuntimeIssue>,
   1770 ) {
   1771     set_last_issue(
   1772         shared,
   1773         match &result {
   1774             Ok(_) => None,
   1775             Err(issue) => Some(issue.clone()),
   1776         },
   1777     );
   1778     let _ = response_sender.send(result);
   1779 }
   1780 
   1781 fn lifecycle_busy_issue(shared: &AppSdkRuntimeShared) -> Option<AppSdkRuntimeIssue> {
   1782     let state = lock_status(shared).state;
   1783     if matches!(
   1784         state,
   1785         AppSdkLifecycleState::Pausing
   1786             | AppSdkLifecycleState::Paused
   1787             | AppSdkLifecycleState::Restoring
   1788             | AppSdkLifecycleState::RebuildingProjections
   1789             | AppSdkLifecycleState::ShuttingDown
   1790     ) {
   1791         Some(AppSdkRuntimeIssue::lifecycle_blocked(state))
   1792     } else {
   1793         None
   1794     }
   1795 }
   1796 
   1797 fn runtime_unavailable_issue(shared: &AppSdkRuntimeShared) -> AppSdkRuntimeIssue {
   1798     let status = lock_status(shared).clone();
   1799     if let Some(issue) = status.last_issue {
   1800         issue
   1801     } else {
   1802         AppSdkRuntimeIssue::runtime_error(
   1803             "sdk_runtime_not_ready",
   1804             format!("app sdk runtime is {:?}", status.state),
   1805         )
   1806     }
   1807 }
   1808 
   1809 fn replace_status(shared: &AppSdkRuntimeShared, status: AppSdkRuntimeStatus) {
   1810     *lock_status(shared) = status;
   1811     shared.status_changed.notify_all();
   1812 }
   1813 
   1814 fn set_last_issue(shared: &AppSdkRuntimeShared, issue: Option<AppSdkRuntimeIssue>) {
   1815     lock_status(shared).last_issue = issue;
   1816     shared.status_changed.notify_all();
   1817 }
   1818 
   1819 fn transition_status_state(shared: &AppSdkRuntimeShared, state: AppSdkLifecycleState) {
   1820     lock_status(shared).state = state;
   1821     shared.status_changed.notify_all();
   1822 }
   1823 
   1824 fn mark_projections_stale(
   1825     shared: &AppSdkRuntimeShared,
   1826     reason: impl Into<String>,
   1827     restore_source: Option<PathBuf>,
   1828 ) -> AppSdkProjectionLifecycleStatus {
   1829     let mut status = lock_status(shared);
   1830     status.projection_lifecycle = AppSdkProjectionLifecycleStatus::stale(reason, restore_source);
   1831     status.state = AppSdkLifecycleState::Ready;
   1832     let projection_lifecycle = status.projection_lifecycle.clone();
   1833     shared.status_changed.notify_all();
   1834     projection_lifecycle
   1835 }
   1836 
   1837 fn begin_projection_rebuild(shared: &AppSdkRuntimeShared) -> AppSdkProjectionLifecycleStatus {
   1838     let restore_source = lock_status(shared)
   1839         .projection_lifecycle
   1840         .restore_source
   1841         .clone();
   1842     let mut status = lock_status(shared);
   1843     status.state = AppSdkLifecycleState::RebuildingProjections;
   1844     status.projection_lifecycle =
   1845         AppSdkProjectionLifecycleStatus::rebuilding("sdk_projection_rebuild", restore_source);
   1846     let projection_lifecycle = status.projection_lifecycle.clone();
   1847     shared.status_changed.notify_all();
   1848     projection_lifecycle
   1849 }
   1850 
   1851 fn complete_projection_rebuild(
   1852     shared: &AppSdkRuntimeShared,
   1853 ) -> Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeIssue> {
   1854     let mut status = lock_status(shared);
   1855     if !matches!(status.state, AppSdkLifecycleState::RebuildingProjections) {
   1856         return Err(AppSdkRuntimeIssue::lifecycle_blocked(status.state));
   1857     }
   1858     status.state = AppSdkLifecycleState::Ready;
   1859     status.projection_lifecycle = AppSdkProjectionLifecycleStatus::current();
   1860     let projection_lifecycle = status.projection_lifecycle.clone();
   1861     shared.status_changed.notify_all();
   1862     Ok(projection_lifecycle)
   1863 }
   1864 
   1865 fn lock_status(shared: &AppSdkRuntimeShared) -> MutexGuard<'_, AppSdkRuntimeStatus> {
   1866     shared
   1867         .status
   1868         .lock()
   1869         .unwrap_or_else(|poisoned| poisoned.into_inner())
   1870 }
   1871 
   1872 fn sdk_error_class_label(error: &RadrootsSdkError) -> String {
   1873     serde_json::to_value(error.class())
   1874         .ok()
   1875         .and_then(|value| value.as_str().map(str::to_owned))
   1876         .unwrap_or_else(|| format!("{:?}", error.class()))
   1877 }
   1878 
   1879 fn serialized_label(value: &(impl Serialize + fmt::Debug)) -> String {
   1880     serde_json::to_value(value)
   1881         .ok()
   1882         .and_then(|value| value.as_str().map(str::to_owned))
   1883         .unwrap_or_else(|| format!("{value:?}"))
   1884 }
   1885 
   1886 #[cfg(test)]
   1887 mod tests {
   1888     use std::{
   1889         fs,
   1890         sync::{
   1891             Arc, Condvar, Mutex,
   1892             atomic::{AtomicBool, Ordering},
   1893             mpsc,
   1894         },
   1895         thread,
   1896         time::{Duration, SystemTime, UNIX_EPOCH},
   1897     };
   1898 
   1899     use radroots_core::{
   1900         RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
   1901         RadrootsCoreQuantityPrice, RadrootsCoreUnit,
   1902     };
   1903     use radroots_events::{
   1904         farm::RadrootsFarmRef,
   1905         ids::{RadrootsDTag, RadrootsInventoryBinId},
   1906         listing::{
   1907             RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
   1908             RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
   1909             RadrootsListingStatus,
   1910         },
   1911     };
   1912     use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrSecretKey};
   1913     use radroots_sdk::{
   1914         BackupRequest, LISTING_PUBLISH_OPERATION_KIND, RadrootsSdk,
   1915         SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy,
   1916     };
   1917 
   1918     use crate::{
   1919         APP_RUNTIME_NAMESPACE, AppDesktopRuntimePaths, AppRuntimeHostEnvironment,
   1920         AppRuntimePlatform,
   1921     };
   1922 
   1923     use super::{
   1924         APP_SDK_STORAGE_DIR_NAME, AppSdkConfig, AppSdkLifecycleState, AppSdkListingPublishRequest,
   1925         AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, AppSdkRestorePreflightRequest,
   1926         AppSdkRuntime, AppSdkRuntimeError, AppSdkRuntimeShared, AppSdkRuntimeStatus,
   1927         AppSdkWorkerCommand, app_sdk_storage_root_from_data_root, transition_status_state,
   1928     };
   1929 
   1930     const SDK_TEST_SELLER_SECRET_KEY_HEX: &str =
   1931         "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
   1932 
   1933     #[test]
   1934     fn sdk_config_uses_app_data_sdk_storage_root() {
   1935         let paths = AppDesktopRuntimePaths::for_desktop(
   1936             AppRuntimePlatform::Macos,
   1937             AppRuntimeHostEnvironment {
   1938                 home_dir: Some("/Users/treesap".into()),
   1939                 ..AppRuntimeHostEnvironment::default()
   1940             },
   1941         )
   1942         .expect("desktop paths should resolve");
   1943         let config =
   1944             AppSdkConfig::from_desktop_paths(&paths, vec!["wss://relay.example".to_owned()]);
   1945 
   1946         assert_eq!(
   1947             config.storage_root,
   1948             paths.app.data.join(APP_SDK_STORAGE_DIR_NAME)
   1949         );
   1950         assert_eq!(
   1951             config.storage_root,
   1952             app_sdk_storage_root_from_data_root(paths.app.data.as_path())
   1953         );
   1954         assert_eq!(config.storage_root.parent(), Some(paths.app.data.as_path()));
   1955         assert!(paths.app.data.ends_with(APP_RUNTIME_NAMESPACE));
   1956         assert_eq!(config.relay_url_policy, AppSdkRelayUrlPolicy::Public);
   1957     }
   1958 
   1959     #[test]
   1960     fn sdk_config_uses_localhost_policy_for_ws_relay_urls() {
   1961         let config = AppSdkConfig::from_app_data_root(
   1962             "/tmp/radroots-app-data".as_ref(),
   1963             vec![
   1964                 "wss://relay.example".to_owned(),
   1965                 "ws://127.0.0.1:8080".to_owned(),
   1966             ],
   1967         );
   1968 
   1969         assert_eq!(config.relay_url_policy, AppSdkRelayUrlPolicy::Localhost);
   1970     }
   1971 
   1972     #[test]
   1973     fn sdk_runtime_reaches_ready_with_directory_storage() {
   1974         let storage_root = temp_storage_root("ready");
   1975         let config = AppSdkConfig::from_app_data_root(
   1976             storage_root
   1977                 .parent()
   1978                 .expect("storage root should have parent"),
   1979             vec!["ws://127.0.0.1:8080".to_owned()],
   1980         );
   1981         let runtime = AppSdkRuntime::start(config).expect("sdk runtime should start");
   1982 
   1983         let status = runtime.wait_for_startup(Duration::from_secs(5));
   1984 
   1985         assert_eq!(status.state, AppSdkLifecycleState::Ready);
   1986         assert_eq!(status.storage_root, storage_root);
   1987         assert_eq!(status.relay_url_policy, AppSdkRelayUrlPolicy::Localhost);
   1988         let storage_paths = status
   1989             .storage_paths
   1990             .expect("storage paths should be present");
   1991         assert_eq!(
   1992             storage_paths.event_store_path,
   1993             storage_root.join("event_store.sqlite")
   1994         );
   1995         assert_eq!(
   1996             storage_paths.outbox_path,
   1997             storage_root.join("outbox.sqlite")
   1998         );
   1999         let storage = runtime
   2000             .storage_status()
   2001             .expect("storage diagnostics should load");
   2002         assert_eq!(storage.storage_kind, "directory");
   2003         assert!(storage.event_store.store.integrity_ok);
   2004         assert!(storage.outbox.store.integrity_ok);
   2005         let integrity = runtime
   2006             .integrity_status()
   2007             .expect("integrity diagnostics should load");
   2008         assert!(integrity.event_store_ok);
   2009         assert!(integrity.outbox_ok);
   2010         let sync = runtime.sync_status().expect("sync diagnostics should load");
   2011         assert_eq!(sync.source, "sdk_canonical_stores");
   2012         assert_eq!(sync.relay_targets.configured_count, 1);
   2013         let diagnostics = runtime.diagnostics().expect("diagnostics should load");
   2014         assert_eq!(diagnostics.runtime.state, AppSdkLifecycleState::Ready);
   2015         assert_eq!(diagnostics.storage.storage_kind, "directory");
   2016         assert_eq!(diagnostics.sync.relay_targets.configured_count, 1);
   2017         runtime.shutdown().expect("sdk runtime should shut down");
   2018         assert_eq!(runtime.status().state, AppSdkLifecycleState::Stopped);
   2019         let _ = fs::remove_dir_all(storage_root);
   2020     }
   2021 
   2022     #[test]
   2023     fn sdk_runtime_enqueues_listing_publish_work() {
   2024         let storage_root = temp_storage_root("listing_enqueue");
   2025         let config = AppSdkConfig::from_app_data_root(
   2026             storage_root
   2027                 .parent()
   2028                 .expect("storage root should have parent"),
   2029             vec!["ws://127.0.0.1:8080".to_owned()],
   2030         );
   2031         let runtime = AppSdkRuntime::start(config).expect("sdk runtime should start");
   2032         assert_eq!(
   2033             runtime.wait_for_startup(Duration::from_secs(5)).state,
   2034             AppSdkLifecycleState::Ready
   2035         );
   2036         let secret_key = RadrootsNostrSecretKey::from_hex(SDK_TEST_SELLER_SECRET_KEY_HEX)
   2037             .expect("secret key should parse");
   2038         let signer_keys = RadrootsNostrKeys::new(secret_key);
   2039         let seller_pubkey = signer_keys.public_key().to_hex();
   2040 
   2041         let receipt = runtime
   2042             .enqueue_listing_publish(AppSdkListingPublishRequest {
   2043                 actor_account_id: "seller-account".to_owned(),
   2044                 actor_pubkey: seller_pubkey.clone(),
   2045                 signer_keys,
   2046                 listing: test_listing(seller_pubkey.as_str()),
   2047                 target_relays: vec!["ws://127.0.0.1:8080".to_owned()],
   2048                 relay_url_policy: AppSdkRelayUrlPolicy::Localhost,
   2049                 idempotency_key: Some("listing-enqueue-idempotency".to_owned()),
   2050             })
   2051             .expect("listing publish should enqueue");
   2052 
   2053         assert_eq!(receipt.operation_kind, LISTING_PUBLISH_OPERATION_KIND);
   2054         assert_eq!(receipt.actor_pubkey, seller_pubkey);
   2055         assert_eq!(receipt.state, "enqueued");
   2056         assert!(!receipt.expected_event_id.is_empty());
   2057         assert_eq!(receipt.expected_event_id, receipt.signed_event_id);
   2058         assert!(receipt.outbox_operation_id > 0);
   2059         assert!(receipt.outbox_event_id > 0);
   2060         assert!(receipt.idempotency_digest_prefix.is_some());
   2061         let sync = runtime.sync_status().expect("sync diagnostics should load");
   2062         assert_eq!(sync.outbox.ready_signed_events, 1);
   2063         runtime.shutdown().expect("sdk runtime should shut down");
   2064         let _ = fs::remove_dir_all(storage_root);
   2065     }
   2066 
   2067     #[test]
   2068     fn sdk_runtime_degrades_with_structured_sdk_error() {
   2069         let storage_root = temp_storage_root("invalid_relay");
   2070         let config = AppSdkConfig::from_app_data_root(
   2071             storage_root
   2072                 .parent()
   2073                 .expect("storage root should have parent"),
   2074             vec!["ws://relay.example".to_owned()],
   2075         );
   2076         let runtime = AppSdkRuntime::start(config).expect("sdk runtime should start");
   2077 
   2078         let status = runtime.wait_for_startup(Duration::from_secs(5));
   2079 
   2080         assert_eq!(status.state, AppSdkLifecycleState::Degraded);
   2081         let issue = status
   2082             .last_issue
   2083             .expect("degraded status should include issue");
   2084         assert_eq!(issue.code, "invalid_relay_url");
   2085         assert_eq!(issue.class, "configuration");
   2086         assert!(!issue.retryable);
   2087         assert!(
   2088             issue
   2089                 .recovery_actions
   2090                 .contains(&"configure_relay_targets".to_owned())
   2091         );
   2092         assert_eq!(issue.detail_json["code"], "invalid_relay_url");
   2093         let error = runtime
   2094             .diagnostics()
   2095             .expect_err("degraded diagnostics should fail");
   2096         match error {
   2097             AppSdkRuntimeError::CommandFailed(issue) => {
   2098                 assert_eq!(issue.code, "invalid_relay_url");
   2099                 assert_eq!(issue.class, "configuration");
   2100                 assert_eq!(issue.detail_json["code"], "invalid_relay_url");
   2101             }
   2102             unexpected => panic!("unexpected degraded diagnostics error: {unexpected:?}"),
   2103         }
   2104         runtime.shutdown().expect("sdk runtime should shut down");
   2105         let _ = fs::remove_dir_all(storage_root);
   2106     }
   2107 
   2108     #[test]
   2109     fn sdk_shutdown_joins_when_normal_command_queue_is_full() {
   2110         let config = AppSdkConfig::from_app_data_root(
   2111             "/tmp/radroots-app-sdk-full-queue".as_ref(),
   2112             vec!["ws://127.0.0.1:8080".to_owned()],
   2113         )
   2114         .with_command_queue_capacity(1);
   2115         let shared = Arc::new(AppSdkRuntimeShared {
   2116             status: Mutex::new(AppSdkRuntimeStatus::from_config(
   2117                 &config,
   2118                 AppSdkLifecycleState::Ready,
   2119                 None,
   2120                 None,
   2121             )),
   2122             status_changed: Condvar::new(),
   2123             shutdown_requested: AtomicBool::new(false),
   2124         });
   2125         let (command_sender, command_receiver) = mpsc::sync_channel(config.command_queue_capacity);
   2126         let worker_shared = Arc::clone(&shared);
   2127         let worker = thread::spawn(move || {
   2128             while !worker_shared.shutdown_requested.load(Ordering::SeqCst) {
   2129                 thread::sleep(Duration::from_millis(1));
   2130             }
   2131             drop(command_receiver);
   2132             transition_status_state(&worker_shared, AppSdkLifecycleState::Stopped);
   2133         });
   2134         let runtime = AppSdkRuntime {
   2135             command_sender: Mutex::new(Some(command_sender)),
   2136             shared,
   2137             worker: Mutex::new(Some(worker)),
   2138         };
   2139         let (response_sender, _response_receiver) = mpsc::channel();
   2140         runtime
   2141             .command_sender
   2142             .lock()
   2143             .expect("command sender lock")
   2144             .as_ref()
   2145             .expect("command sender")
   2146             .try_send(AppSdkWorkerCommand::Diagnostics(response_sender))
   2147             .expect("normal command queue should fill");
   2148 
   2149         assert!(matches!(
   2150             runtime.sync_status(),
   2151             Err(AppSdkRuntimeError::CommandQueueFull)
   2152         ));
   2153         assert_eq!(runtime.status().state, AppSdkLifecycleState::Ready);
   2154 
   2155         runtime
   2156             .shutdown()
   2157             .expect("shutdown should not depend on normal command queue capacity");
   2158 
   2159         assert_eq!(runtime.status().state, AppSdkLifecycleState::Stopped);
   2160     }
   2161 
   2162     #[test]
   2163     fn sdk_restore_preflight_marks_projections_stale_without_writing_destination() {
   2164         let backup_source_root = temp_storage_root("restore_backup_source");
   2165         let backup_archive = backup_source_root
   2166             .parent()
   2167             .expect("backup source should have parent")
   2168             .join("backup_archive");
   2169         let tokio = tokio::runtime::Builder::new_current_thread()
   2170             .enable_all()
   2171             .build()
   2172             .expect("tokio runtime");
   2173         let sdk = tokio
   2174             .block_on(
   2175                 RadrootsSdk::builder()
   2176                     .directory_storage(backup_source_root.clone())
   2177                     .relay_url_policy(SdkRuntimeRelayUrlPolicy::Localhost)
   2178                     .relay_url("ws://127.0.0.1:8080")
   2179                     .build(),
   2180             )
   2181             .expect("source sdk should build");
   2182         tokio
   2183             .block_on(sdk.backup(BackupRequest::new(backup_archive.clone())))
   2184             .expect("backup should complete");
   2185 
   2186         let app_storage_root = temp_storage_root("restore_preflight_destination");
   2187         let app_data_root = app_storage_root
   2188             .parent()
   2189             .expect("app storage root should have parent")
   2190             .to_path_buf();
   2191         let config = AppSdkConfig::from_app_data_root(
   2192             app_data_root.as_path(),
   2193             vec!["ws://127.0.0.1:8080".to_owned()],
   2194         );
   2195         let runtime = AppSdkRuntime::start(config).expect("sdk runtime should start");
   2196         assert_eq!(
   2197             runtime.wait_for_startup(Duration::from_secs(5)).state,
   2198             AppSdkLifecycleState::Ready
   2199         );
   2200         let sentinel = app_storage_root.join("restore-preflight-sentinel");
   2201         fs::write(&sentinel, "existing destination").expect("sentinel should write");
   2202 
   2203         let receipt = runtime
   2204             .restore_preflight(
   2205                 AppSdkRestorePreflightRequest::new(backup_archive.clone())
   2206                     .with_overwrite_existing_sdk_storage(true),
   2207             )
   2208             .expect("restore preflight should succeed");
   2209 
   2210         assert_eq!(receipt.state, "dry_run");
   2211         assert_eq!(receipt.destination, app_storage_root);
   2212         assert_eq!(receipt.restored_paths, None);
   2213         assert!(sentinel.exists());
   2214         assert_eq!(
   2215             receipt.projection_lifecycle.state,
   2216             AppSdkProjectionLifecycleState::Stale
   2217         );
   2218         assert_eq!(
   2219             receipt.projection_lifecycle.reason.as_deref(),
   2220             Some("sdk_restore_preflight")
   2221         );
   2222         assert_eq!(
   2223             runtime.status().projection_lifecycle.state,
   2224             AppSdkProjectionLifecycleState::Stale
   2225         );
   2226         assert_eq!(runtime.status().state, AppSdkLifecycleState::Ready);
   2227         runtime.shutdown().expect("sdk runtime should shut down");
   2228         let _ = fs::remove_dir_all(
   2229             backup_source_root
   2230                 .parent()
   2231                 .expect("backup source should have parent"),
   2232         );
   2233         let _ = fs::remove_dir_all(app_data_root);
   2234     }
   2235 
   2236     #[test]
   2237     fn sdk_projection_rebuild_state_rejects_conflicting_commands() {
   2238         let storage_root = temp_storage_root("projection_rebuild");
   2239         let config = AppSdkConfig::from_app_data_root(
   2240             storage_root
   2241                 .parent()
   2242                 .expect("storage root should have parent"),
   2243             vec!["ws://127.0.0.1:8080".to_owned()],
   2244         );
   2245         let runtime = AppSdkRuntime::start(config).expect("sdk runtime should start");
   2246         assert_eq!(
   2247             runtime.wait_for_startup(Duration::from_secs(5)).state,
   2248             AppSdkLifecycleState::Ready
   2249         );
   2250 
   2251         let rebuilding = runtime
   2252             .begin_projection_rebuild()
   2253             .expect("projection rebuild should start");
   2254 
   2255         assert_eq!(rebuilding.state, AppSdkProjectionLifecycleState::Rebuilding);
   2256         assert_eq!(
   2257             runtime.status().state,
   2258             AppSdkLifecycleState::RebuildingProjections
   2259         );
   2260         let error = runtime
   2261             .sync_status()
   2262             .expect_err("sync status should wait for rebuild completion");
   2263         match error {
   2264             AppSdkRuntimeError::CommandFailed(issue) => {
   2265                 assert_eq!(issue.code, "sdk_lifecycle_busy");
   2266                 assert_eq!(issue.detail_json["state"], "RebuildingProjections");
   2267             }
   2268             unexpected => panic!("unexpected lifecycle error: {unexpected:?}"),
   2269         }
   2270 
   2271         let complete = runtime
   2272             .complete_projection_rebuild()
   2273             .expect("projection rebuild should complete");
   2274 
   2275         assert_eq!(complete.state, AppSdkProjectionLifecycleState::Current);
   2276         assert_eq!(runtime.status().state, AppSdkLifecycleState::Ready);
   2277         runtime
   2278             .sync_status()
   2279             .expect("sync status should work after rebuild");
   2280         runtime.shutdown().expect("sdk runtime should shut down");
   2281         let _ = fs::remove_dir_all(storage_root);
   2282     }
   2283 
   2284     fn temp_storage_root(label: &str) -> std::path::PathBuf {
   2285         let nanos = SystemTime::now()
   2286             .duration_since(UNIX_EPOCH)
   2287             .expect("clock")
   2288             .as_nanos();
   2289         std::env::temp_dir()
   2290             .join(format!("radroots_app_sdk_runtime_{label}_{nanos}"))
   2291             .join(APP_SDK_STORAGE_DIR_NAME)
   2292     }
   2293 
   2294     fn test_listing(seller_pubkey: &str) -> RadrootsListing {
   2295         let bin_id = RadrootsInventoryBinId::parse("bin-1").expect("bin id");
   2296         RadrootsListing {
   2297             d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAQ").expect("d tag"),
   2298             published_at: None,
   2299             farm: RadrootsFarmRef {
   2300                 pubkey: seller_pubkey.to_owned(),
   2301                 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(),
   2302             },
   2303             product: RadrootsListingProduct {
   2304                 key: "coffee".to_owned(),
   2305                 title: "Coffee".to_owned(),
   2306                 category: "coffee".to_owned(),
   2307                 summary: Some("Single origin coffee".to_owned()),
   2308                 process: None,
   2309                 lot: None,
   2310                 location: None,
   2311                 profile: None,
   2312                 year: None,
   2313             },
   2314             primary_bin_id: bin_id.clone(),
   2315             bins: vec![RadrootsListingBin {
   2316                 bin_id,
   2317                 quantity: RadrootsCoreQuantity::new(
   2318                     RadrootsCoreDecimal::from(1000u32),
   2319                     RadrootsCoreUnit::MassG,
   2320                 ),
   2321                 price_per_canonical_unit: RadrootsCoreQuantityPrice {
   2322                     amount: RadrootsCoreMoney::new(
   2323                         RadrootsCoreDecimal::from(20u32),
   2324                         RadrootsCoreCurrency::USD,
   2325                     ),
   2326                     quantity: RadrootsCoreQuantity::new(
   2327                         RadrootsCoreDecimal::from(1u32),
   2328                         RadrootsCoreUnit::MassG,
   2329                     ),
   2330                 },
   2331                 display_amount: None,
   2332                 display_unit: None,
   2333                 display_label: None,
   2334                 display_price: None,
   2335                 display_price_unit: None,
   2336             }],
   2337             resource_area: None,
   2338             plot: None,
   2339             discounts: None,
   2340             inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
   2341             availability: Some(RadrootsListingAvailability::Status {
   2342                 status: RadrootsListingStatus::Active,
   2343             }),
   2344             delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
   2345             location: Some(RadrootsListingLocation {
   2346                 primary: "North Farm".to_owned(),
   2347                 city: None,
   2348                 region: None,
   2349                 country: Some("US".to_owned()),
   2350                 lat: None,
   2351                 lng: None,
   2352                 geohash: None,
   2353             }),
   2354             images: None,
   2355         }
   2356     }
   2357 }