cli

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

commit e549d4847a791a5c87e9562aedf7d92f4d7bdd87
parent ab7dd72d0872572f32b76d508cc7ba0e0e03bd49
Author: triesap <tyson@radroots.org>
Date:   Sun, 24 May 2026 11:17:44 +0000

cli: consume app-authored order records

Diffstat:
Msrc/domain/runtime.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 6++++++
Msrc/operation_adapter.rs | 17+++++++++++++----
Msrc/operation_order.rs | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/operation_registry.rs | 35++++++++++++++++++++++++++++++++++-
Msrc/runtime/order.rs | 783++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/runtime_args.rs | 6++++++
Msrc/target_cli.rs | 24++++++++++++++++++++++++
Mtests/target_cli.rs | 378++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
9 files changed, 1368 insertions(+), 82 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1409,6 +1409,106 @@ impl OrderListView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderAppRecordListView { + pub state: String, + pub source: String, + pub count: usize, + pub limit: u32, + pub has_more: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_before_change_seq: Option<i64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_before_seq: Option<i64>, + pub local_events_db: String, + pub records: Vec<OrderAppRecordSummaryView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl OrderAppRecordListView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderAppRecordSummaryView { + pub record_id: String, + pub seq: i64, + pub change_seq: i64, + pub superseded_count: usize, + pub record_kind: String, + pub status: String, + pub source_runtime: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub farm_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub order_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_pubkey: Option<String>, + pub ready_for_submit: bool, + pub exportable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderAppRecordExportView { + pub state: String, + pub source: String, + pub record_id: String, + pub dry_run: bool, + pub file: String, + pub valid: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub order_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_actor_source: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_pubkey: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<OrderIssueView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl OrderAppRecordExportView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "missing" => CommandDisposition::NotFound, + "invalid" | "stale" | "unsupported" => CommandDisposition::ValidationFailed, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct OrderSubmitView { pub state: String, pub source: String, diff --git a/src/main.rs b/src/main.rs @@ -298,6 +298,12 @@ fn execute_request( TargetOperationRequest::OrderList(request) => { execute_with(OrderOperationService::new(config), request) } + TargetOperationRequest::OrderAppList(request) => { + execute_with(OrderOperationService::new(config), request) + } + TargetOperationRequest::OrderAppExport(request) => { + execute_with(OrderOperationService::new(config), request) + } TargetOperationRequest::OrderRebind(request) => { execute_with(OrderOperationService::new(config), request) } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1285,10 +1285,10 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati AccountCommand, AccountSelectionCommand, BasketAdjustmentCommand, BasketCommand, BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand, FarmProfileCommand, ListingAppCommand, ListingCommand, MarketCommand, - MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, - OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, - OrderSettlementCommand, OrderStatusCommand, TargetCommand, ValidationCommand, - ValidationReceiptCommand, + MarketListingCommand, MarketProductCommand, OrderAppCommand, OrderCommand, + OrderEventCommand, OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand, + OrderRevisionCommand, OrderSettlementCommand, OrderStatusCommand, TargetCommand, + ValidationCommand, ValidationReceiptCommand, }; let mut input = OperationData::new(); @@ -1458,6 +1458,13 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "order_id", &args.order_id); } OrderCommand::Get(args) => insert_string(&mut input, "order_id", &args.order_id), + OrderCommand::App(args) => match &args.command { + OrderAppCommand::Export(args) => { + insert_string(&mut input, "record_id", &args.record_id); + insert_path(&mut input, "output", &args.output); + } + OrderAppCommand::List => {} + }, OrderCommand::Rebind(args) => { insert_string(&mut input, "order_id", &args.order_id); insert_string(&mut input, "selector", &args.selector); @@ -1662,6 +1669,8 @@ target_operation_contracts! { OrderSubmit => (OrderSubmitRequest, OrderSubmitResult, "order.submit"), OrderGet => (OrderGetRequest, OrderGetResult, "order.get"), OrderList => (OrderListRequest, OrderListResult, "order.list"), + OrderAppList => (OrderAppListRequest, OrderAppListResult, "order.app.list"), + OrderAppExport => (OrderAppExportRequest, OrderAppExportResult, "order.app.export"), OrderRebind => (OrderRebindRequest, OrderRebindResult, "order.rebind"), OrderAccept => (OrderAcceptRequest, OrderAcceptResult, "order.accept"), OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"), diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -1,15 +1,18 @@ +use std::path::PathBuf; + use serde::Serialize; use serde_json::{Value, json}; use crate::deferred_payment::deferred_payment_message; use crate::domain::runtime::{ - CommandDisposition, OrderCancellationView, OrderDecisionView, OrderFulfillmentView, - OrderRebindView, OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, - OrderStatusView, OrderSubmitView, + CommandDisposition, OrderAppRecordExportView, OrderCancellationView, OrderDecisionView, + OrderFulfillmentView, OrderRebindView, OrderReceiptView, OrderRevisionDecisionView, + OrderRevisionProposalView, OrderStatusView, OrderSubmitView, }; use crate::operation_adapter::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, OperationResult, OperationResultData, OperationService, OrderAcceptRequest, OrderAcceptResult, + OrderAppExportRequest, OrderAppExportResult, OrderAppListRequest, OrderAppListResult, OrderCancelRequest, OrderCancelResult, OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult, OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult, @@ -24,9 +27,10 @@ use crate::operation_adapter::{ use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime_args::{ - OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderRebindArgs, - OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, - OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, RecordLookupArgs, + OrderAppRecordExportArgs, OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, + OrderFulfillmentArgs, OrderRebindArgs, OrderReceiptArgs, OrderRevisionDecisionArg, + OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, + RecordLookupArgs, }; const ORDER_EVENT_WATCH_DEFERRED_REASON: &str = "relay-backed order event watch is not implemented"; @@ -101,6 +105,40 @@ impl OperationService<OrderListRequest> for OrderOperationService<'_> { } } +impl OperationService<OrderAppListRequest> for OrderOperationService<'_> { + type Result = OrderAppListResult; + + fn execute( + &self, + _request: OperationRequest<OrderAppListRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = map_runtime(crate::runtime::order::app_record_list(self.config))?; + serialized_target_result::<OrderAppListResult, _>(&view) + } +} + +impl OperationService<OrderAppExportRequest> for OrderOperationService<'_> { + type Result = OrderAppExportResult; + + fn execute( + &self, + request: OperationRequest<OrderAppExportRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = OrderAppRecordExportArgs { + record_id: required_string_input(&request, "record_id")?, + output: optional_path_input(&request, "output"), + }; + let mut config = self.config.clone(); + if request.context.dry_run { + config.output.dry_run = true; + } + let view = crate::runtime::order::app_record_export(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + order_app_record_export_result::<OrderAppExportResult>(request.operation_id(), &view) + } +} + impl OperationService<OrderRebindRequest> for OrderOperationService<'_> { type Result = OrderRebindResult; @@ -1250,6 +1288,50 @@ where } } +fn order_app_record_export_result<R>( + operation_id: &str, + view: &OrderAppRecordExportView, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + match view.disposition() { + CommandDisposition::Success => serialized_target_result::<R, _>(view), + CommandDisposition::NotFound => Err(OperationAdapterError::not_found_with_detail( + operation_id, + view.reason.clone().unwrap_or_else(|| { + format!( + "app-authored local order record `{}` was not found", + view.record_id + ) + }), + serde_json::to_value(view).unwrap_or(Value::Null), + )), + CommandDisposition::ValidationFailed => { + Err(OperationAdapterError::validation_failed_with_detail( + operation_id, + view.reason.clone().unwrap_or_else(|| { + format!( + "app-authored local order record `{}` cannot be exported", + view.record_id + ) + }), + serde_json::to_value(view).unwrap_or(Value::Null), + )) + } + disposition => Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + view.reason.clone().unwrap_or_else(|| { + format!( + "app-authored local order record export finished with state `{}`", + view.state + ) + }), + )), + } +} + fn order_rebind_result<R>( operation_id: &str, view: &OrderRebindView, @@ -1431,6 +1513,13 @@ where .map(str::to_owned) } +fn optional_path_input<P>(request: &OperationRequest<P>, key: &str) -> Option<PathBuf> +where + P: OperationRequestPayload + OperationRequestData, +{ + string_input(request, key).map(PathBuf::from) +} + fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool> where P: OperationRequestPayload + OperationRequestData, diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -953,6 +953,36 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ false ), operation!( + "order.app.list", + "radroots order app list", + "order", + "order_app_list", + "OrderAppListRequest", + "OrderAppListResult", + "List app-authored shared local order records.", + Buyer, + false, + None, + Low, + false, + false + ), + operation!( + "order.app.export", + "radroots order app export", + "order", + "order_app_export", + "OrderAppExportRequest", + "OrderAppExportResult", + "Export an app-authored shared order record as a CLI draft.", + Buyer, + true, + None, + Medium, + false, + true + ), + operation!( "order.rebind", "radroots order rebind", "order", @@ -1381,6 +1411,8 @@ mod tests { "order.submit", "order.get", "order.list", + "order.app.list", + "order.app.export", "order.rebind", "order.accept", "order.decline", @@ -1434,6 +1466,7 @@ mod tests { "basket.adjustment.remove", "basket.quote.create", "order.submit", + "order.app.export", "order.rebind", "order.accept", "order.decline", @@ -1455,7 +1488,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 76); + assert_eq!(OPERATION_REGISTRY.len(), 78); } #[test] diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -46,6 +47,10 @@ use radroots_events_codec::trade::{ active_trade_settlement_decision_from_event, }; use radroots_events_codec::wire::WireEventParts; +use radroots_local_events::{ + BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecord, LocalRecordFamily, + LocalRecordStatus, SourceRuntime, validate_buyer_order_request_local_work_payload, +}; use radroots_nostr::prelude::{ RadrootsNostrEvent, RadrootsNostrFilter, radroots_event_from_nostr, radroots_nostr_filter_tag, radroots_nostr_kind, @@ -78,9 +83,10 @@ use radroots_trade::order::{ reduce_active_order_events, reduce_listing_inventory_accounting, }; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{Value, json}; use crate::domain::runtime::{ + OrderAppRecordExportView, OrderAppRecordListView, OrderAppRecordSummaryView, OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderEventListEntryView, OrderEventListView, OrderFulfillmentView, OrderGetView, OrderInventoryBinView, OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderPaymentView, @@ -96,20 +102,25 @@ use crate::runtime::direct_relay::{ DirectRelayFailure, DirectRelayFetchError, DirectRelayFetchReceipt, DirectRelayPublishReceipt, fetch_events_from_relays, publish_parts_with_identity, }; +use crate::runtime::local_events::{ + get_shared_record, list_shared_records_before, list_shared_records_latest, + shared_local_events_db_path, +}; use crate::runtime::signer::ActorWriteBindingError; use crate::runtime::sync::{ RelayIngestScope, freshness_for_scope, freshness_requires_refresh, market_refresh, }; use crate::runtime_args::{ - OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftCreateArgs, - OrderFulfillmentArgs, OrderPaymentArgs, OrderRebindArgs, OrderReceiptArgs, - OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs, - OrderSettlementArgs, OrderSettlementDecisionArg, OrderStatusArgs, OrderSubmitArgs, - RecordLookupArgs, + OrderAppRecordExportArgs, OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, + OrderDraftCreateArgs, OrderFulfillmentArgs, OrderPaymentArgs, OrderRebindArgs, + OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, + OrderRevisionProposeArgs, OrderSettlementArgs, OrderSettlementDecisionArg, OrderStatusArgs, + OrderSubmitArgs, RecordLookupArgs, }; const ORDER_DRAFT_KIND: &str = "order_draft_v1"; const ORDER_SOURCE: &str = "local order drafts · local first"; +const ORDER_APP_RECORD_SOURCE: &str = "app-authored shared local order records"; const ORDER_SUBMIT_SOURCE: &str = "direct Nostr relay publish · local key"; const ORDER_DECISION_SOURCE: &str = "direct Nostr relay decision publish · local key"; const ORDER_REVISION_PROPOSAL_SOURCE: &str = @@ -127,6 +138,7 @@ const ORDER_EVENT_LIST_RELAY_ACTION: &str = "radroots --relay wss://relay.example.com order event list"; const ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; const ORDER_BUYER_ACTOR_SOURCE_REBIND: &str = "order_rebind"; +const ORDER_APP_RECORD_LIST_LIMIT: u32 = 500; const ORDER_ACTOR_CONTEXT_ORDER_DRAFT: &str = "order_draft"; const ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT: &str = "resolved_account"; const ORDER_ACTOR_CONTEXT_NETWORK_ONLY: &str = "network_only"; @@ -186,6 +198,19 @@ struct LoadedOrderDraft { } #[derive(Debug, Clone)] +struct LoadedAppOrderRecord { + record: LocalEventRecord, + loaded: LoadedOrderDraft, + source_issues: Vec<OrderIssueView>, +} + +#[derive(Debug, Clone)] +struct AppOrderRecordListEntry { + record: LocalEventRecord, + superseded_count: usize, +} + +#[derive(Debug, Clone)] struct ResolvedOrderListing { listing_addr: String, listing_event_id: String, @@ -487,6 +512,13 @@ pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetVi let lookup = args.key.clone(); let file = draft_lookup_path(config, lookup.as_str()); if !file.exists() { + if let Some(app_order) = load_app_order_record_for_lookup(config, lookup.as_str())? { + return view_from_loaded_with_source_issues( + config, + app_order.loaded, + app_order.source_issues.as_slice(), + ); + } return Ok(OrderGetView { state: "missing".to_owned(), source: ORDER_SOURCE.to_owned(), @@ -549,27 +581,34 @@ pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetVi pub fn list(config: &RuntimeConfig) -> Result<OrderListView, RuntimeError> { let dir = drafts_dir(config); - if !dir.exists() { - return Ok(OrderListView { - state: "empty".to_owned(), - source: ORDER_SOURCE.to_owned(), - count: 0, - orders: Vec::new(), - actions: vec!["radroots basket create".to_owned()], - }); - } - let mut orders = Vec::new(); - for entry in fs::read_dir(&dir)? { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("toml") { - continue; + let mut local_order_ids = HashSet::new(); + if dir.exists() { + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("toml") { + continue; + } + match load_draft(path.as_path()) { + Ok(loaded) => { + local_order_ids.insert(loaded.document.order.order_id.clone()); + orders.push(summary_from_loaded(config, &loaded)?); + } + Err(reason) => orders.push(summary_for_invalid_file(path.as_path(), reason)), + } } - match load_draft(path.as_path()) { - Ok(loaded) => orders.push(summary_from_loaded(config, &loaded)?), - Err(reason) => orders.push(summary_for_invalid_file(path.as_path(), reason)), + } + for entry in current_app_order_record_entries(app_order_local_records(config)?) { + let app_order = load_app_order_record_from_record(config, entry.record.clone())?; + if local_order_ids.contains(&app_order.loaded.document.order.order_id) { + continue; } + orders.push(summary_from_loaded_with_source_issues( + config, + &app_order.loaded, + app_order.source_issues.as_slice(), + )?); } orders.sort_by(|left, right| { @@ -604,12 +643,247 @@ pub fn list(config: &RuntimeConfig) -> Result<OrderListView, RuntimeError> { }) } +pub fn app_record_list(config: &RuntimeConfig) -> Result<OrderAppRecordListView, RuntimeError> { + let database_path = shared_local_events_db_path(config)?; + let mut entries = current_app_order_record_entries(app_order_local_records(config)?); + let has_more = entries.len() > ORDER_APP_RECORD_LIST_LIMIT as usize; + if has_more { + entries.truncate(ORDER_APP_RECORD_LIST_LIMIT as usize); + } + let next_cursor = if has_more { + entries + .last() + .map(|entry| (entry.record.change_seq, entry.record.seq)) + } else { + None + }; + let records = entries + .iter() + .map(|entry| app_order_record_summary(config, &entry.record, entry.superseded_count)) + .collect::<Result<Vec<_>, _>>()?; + let state = if records.is_empty() { "empty" } else { "ready" }; + let actions = if records.is_empty() { + vec!["place a buyer order in radroots_app".to_owned()] + } else { + Vec::new() + }; + + Ok(OrderAppRecordListView { + state: state.to_owned(), + source: ORDER_APP_RECORD_SOURCE.to_owned(), + count: records.len(), + limit: ORDER_APP_RECORD_LIST_LIMIT, + has_more, + next_before_change_seq: next_cursor.map(|(change_seq, _)| change_seq), + next_before_seq: next_cursor.map(|(_, seq)| seq), + local_events_db: database_path.display().to_string(), + records, + actions, + }) +} + +pub fn app_record_export( + config: &RuntimeConfig, + args: &OrderAppRecordExportArgs, +) -> Result<OrderAppRecordExportView, RuntimeError> { + let Some(record) = get_shared_record(config, args.record_id.as_str())? else { + return Ok(OrderAppRecordExportView { + state: "missing".to_owned(), + source: ORDER_APP_RECORD_SOURCE.to_owned(), + record_id: args.record_id.clone(), + dry_run: config.output.dry_run, + file: args + .output + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + valid: false, + order_id: None, + listing_addr: None, + listing_event_id: None, + buyer_account_id: None, + buyer_pubkey: None, + buyer_actor_source: None, + seller_pubkey: None, + issues: Vec::new(), + reason: Some(format!( + "app-authored local order record `{}` was not found", + args.record_id + )), + actions: vec!["radroots order app list".to_owned()], + }); + }; + + if let Some(current_record) = current_app_order_record_for(config, &record)? + && current_record.record_id != record.record_id + { + let order_id = app_order_record_order_id(&record); + return Ok(OrderAppRecordExportView { + state: "stale".to_owned(), + source: ORDER_APP_RECORD_SOURCE.to_owned(), + record_id: args.record_id.clone(), + dry_run: config.output.dry_run, + file: args + .output + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + valid: false, + order_id: order_id.clone(), + listing_addr: record.listing_addr.clone(), + listing_event_id: None, + buyer_account_id: record.owner_account_id.clone(), + buyer_pubkey: record.owner_pubkey.clone(), + buyer_actor_source: None, + seller_pubkey: None, + issues: vec![issue_with_code( + "app_order_stale", + "record_id", + format!( + "app-authored local order record `{}` was superseded by `{}`", + record.record_id, current_record.record_id + ), + )], + reason: Some(format!( + "app-authored local order record `{}` was superseded by current record `{}`", + record.record_id, current_record.record_id + )), + actions: vec![ + format!("radroots order app export {}", current_record.record_id), + "radroots order app list".to_owned(), + ], + }); + } + + let app_order = load_app_order_record_from_record(config, record)?; + let mut issues = source_and_document_issues(config, &app_order)?; + if !issues.is_empty() { + let state = app_order_export_failure_state(issues.as_slice()); + return Ok(OrderAppRecordExportView { + state: state.to_owned(), + source: ORDER_APP_RECORD_SOURCE.to_owned(), + record_id: args.record_id.clone(), + dry_run: config.output.dry_run, + file: args + .output + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + valid: false, + order_id: Some(app_order.loaded.document.order.order_id.clone()), + listing_addr: non_empty_string(app_order.loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string( + app_order.loaded.document.order.listing_event_id.clone(), + ), + buyer_account_id: buyer_account_id(&app_order.loaded.document), + buyer_pubkey: non_empty_string(app_order.loaded.document.order.buyer_pubkey.clone()), + buyer_actor_source: buyer_actor_source(&app_order.loaded.document), + seller_pubkey: non_empty_string(app_order.loaded.document.order.seller_pubkey.clone()), + issues, + reason: Some(format!( + "app-authored local order record `{}` is not ready as a CLI order draft", + args.record_id + )), + actions: vec!["radroots order app list".to_owned()], + }); + } + + let output_path = order_export_output_path( + config, + args.output.as_ref(), + app_order.loaded.document.order.order_id.as_str(), + ); + validate_order_export_output_target(output_path.as_path())?; + if !config.output.dry_run { + save_draft(output_path.as_path(), &app_order.loaded.document)?; + } + issues.clear(); + + Ok(OrderAppRecordExportView { + state: if config.output.dry_run { + "dry_run" + } else { + "exported" + } + .to_owned(), + source: ORDER_APP_RECORD_SOURCE.to_owned(), + record_id: args.record_id.clone(), + dry_run: config.output.dry_run, + file: output_path.display().to_string(), + valid: true, + order_id: Some(app_order.loaded.document.order.order_id.clone()), + listing_addr: non_empty_string(app_order.loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string( + app_order.loaded.document.order.listing_event_id.clone(), + ), + buyer_account_id: buyer_account_id(&app_order.loaded.document), + buyer_pubkey: non_empty_string(app_order.loaded.document.order.buyer_pubkey.clone()), + buyer_actor_source: buyer_actor_source(&app_order.loaded.document), + seller_pubkey: non_empty_string(app_order.loaded.document.order.seller_pubkey.clone()), + issues, + reason: Some(if config.output.dry_run { + "dry run requested; order draft was not written".to_owned() + } else { + "app-authored local order record exported as a CLI order draft".to_owned() + }), + actions: vec![ + format!( + "radroots order get {}", + app_order.loaded.document.order.order_id + ), + format!( + "radroots --relay wss://relay.example.com order submit {}", + app_order.loaded.document.order.order_id + ), + ], + }) +} + pub fn submit( config: &RuntimeConfig, args: &OrderSubmitArgs, ) -> Result<OrderSubmitView, RuntimeError> { let file = draft_lookup_path(config, args.key.as_str()); - if !file.exists() { + let (loaded, source_issues) = if file.exists() { + match load_draft(file.as_path()) { + Ok(loaded) => (loaded, Vec::new()), + Err(reason) => { + return Ok(OrderSubmitView { + state: "error".to_owned(), + source: ORDER_SOURCE.to_owned(), + order_id: args.key.clone(), + file: file.display().to_string(), + listing_lookup: None, + listing_addr: None, + listing_event_id: None, + buyer_account_id: None, + buyer_pubkey: None, + buyer_actor_source: None, + buyer_custody: None, + buyer_write_capable: None, + seller_pubkey: None, + event_id: None, + event_kind: None, + dry_run: config.output.dry_run, + deduplicated: false, + target_relays: Vec::new(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + idempotency_key: args.idempotency_key.clone(), + signer_mode: None, + signer_session_id: None, + requested_signer_session_id: None, + reason: Some(reason), + job: None, + issues: Vec::new(), + actions: Vec::new(), + }); + } + } + } else if let Some(app_order) = load_app_order_record_for_lookup(config, args.key.as_str())? { + (app_order.loaded, app_order.source_issues) + } else { return Ok(OrderSubmitView { state: "missing".to_owned(), source: ORDER_SOURCE.to_owned(), @@ -644,46 +918,10 @@ pub fn submit( "radroots basket create".to_owned(), ], }); - } - - let loaded = match load_draft(file.as_path()) { - Ok(loaded) => loaded, - Err(reason) => { - return Ok(OrderSubmitView { - state: "error".to_owned(), - source: ORDER_SOURCE.to_owned(), - order_id: args.key.clone(), - file: file.display().to_string(), - listing_lookup: None, - listing_addr: None, - listing_event_id: None, - buyer_account_id: None, - buyer_pubkey: None, - buyer_actor_source: None, - buyer_custody: None, - buyer_write_capable: None, - seller_pubkey: None, - event_id: None, - event_kind: None, - dry_run: config.output.dry_run, - deduplicated: false, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, - reason: Some(reason), - job: None, - issues: Vec::new(), - actions: Vec::new(), - }); - } }; - let issues = collect_issues(&loaded.document); + let mut issues = collect_issues(&loaded.document); + issues.extend(source_issues.clone()); if !issues.is_empty() { let mut actions = actions_for_document(&loaded.document, loaded.file.as_path(), &issues); actions.push(format!( @@ -9054,6 +9292,14 @@ fn view_from_loaded( config: &RuntimeConfig, loaded: LoadedOrderDraft, ) -> Result<OrderGetView, RuntimeError> { + view_from_loaded_with_source_issues(config, loaded, &[]) +} + +fn view_from_loaded_with_source_issues( + config: &RuntimeConfig, + loaded: LoadedOrderDraft, + source_issues: &[OrderIssueView], +) -> Result<OrderGetView, RuntimeError> { let OrderInspection { state, ready_for_submit, @@ -9063,7 +9309,7 @@ fn view_from_loaded( buyer_custody, buyer_write_capable, issues, - } = inspect_document(config, &loaded.document)?; + } = inspect_document_with_source_issues(config, &loaded.document, source_issues)?; let actions = actions_for_document(&loaded.document, loaded.file.as_path(), issues.as_slice()); @@ -9107,6 +9353,14 @@ fn summary_from_loaded( config: &RuntimeConfig, loaded: &LoadedOrderDraft, ) -> Result<OrderSummaryView, RuntimeError> { + summary_from_loaded_with_source_issues(config, loaded, &[]) +} + +fn summary_from_loaded_with_source_issues( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + source_issues: &[OrderIssueView], +) -> Result<OrderSummaryView, RuntimeError> { let OrderInspection { state, ready_for_submit, @@ -9116,7 +9370,7 @@ fn summary_from_loaded( buyer_custody, buyer_write_capable, issues, - } = inspect_document(config, &loaded.document)?; + } = inspect_document_with_source_issues(config, &loaded.document, source_issues)?; Ok(OrderSummaryView { id: loaded.document.order.order_id.clone(), @@ -9166,10 +9420,384 @@ fn summary_for_invalid_file(path: &Path, reason: String) -> OrderSummaryView { } } +fn app_order_local_records(config: &RuntimeConfig) -> Result<Vec<LocalEventRecord>, RuntimeError> { + let mut app_records = Vec::new(); + let mut before_cursor = None::<(i64, i64)>; + loop { + let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor { + list_shared_records_before( + config, + before_change_seq, + before_seq, + ORDER_APP_RECORD_LIST_LIMIT, + )? + } else { + list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)? + }; + let Some(next_cursor) = shared_records + .last() + .map(|record| (record.change_seq, record.seq)) + else { + break; + }; + let has_more = shared_records.len() == ORDER_APP_RECORD_LIST_LIMIT as usize; + app_records.extend(shared_records.into_iter().filter(is_app_order_local_record)); + if !has_more { + break; + } + before_cursor = Some(next_cursor); + } + Ok(app_records) +} + +fn is_app_order_local_record(record: &LocalEventRecord) -> bool { + record.source_runtime == SourceRuntime::App + && record.family == LocalRecordFamily::LocalWork + && local_record_kind(record).as_deref() == Some(BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND) +} + +fn current_app_order_record_entries( + mut records: Vec<LocalEventRecord>, +) -> Vec<AppOrderRecordListEntry> { + records.sort_by(|left, right| { + right + .change_seq + .cmp(&left.change_seq) + .then_with(|| right.seq.cmp(&left.seq)) + .then_with(|| left.record_id.cmp(&right.record_id)) + }); + + let mut entries = Vec::<AppOrderRecordListEntry>::new(); + let mut seen = HashMap::<String, usize>::new(); + for record in records { + let key = app_order_record_current_key(&record); + if let Some(index) = seen.get(&key).copied() { + entries[index].superseded_count += 1; + } else { + seen.insert(key, entries.len()); + entries.push(AppOrderRecordListEntry { + record, + superseded_count: 0, + }); + } + } + entries +} + +fn current_app_order_record_for( + config: &RuntimeConfig, + record: &LocalEventRecord, +) -> Result<Option<LocalEventRecord>, RuntimeError> { + let key = app_order_record_current_key(record); + Ok(app_order_local_records(config)? + .into_iter() + .filter(|candidate| app_order_record_current_key(candidate) == key) + .max_by(|left, right| { + left.change_seq + .cmp(&right.change_seq) + .then_with(|| left.seq.cmp(&right.seq)) + })) +} + +fn load_app_order_record_for_lookup( + config: &RuntimeConfig, + lookup: &str, +) -> Result<Option<LoadedAppOrderRecord>, RuntimeError> { + if let Some(record) = get_shared_record(config, lookup)? + && is_app_order_local_record(&record) + { + return load_app_order_record_from_record(config, record).map(Some); + } + for entry in current_app_order_record_entries(app_order_local_records(config)?) { + if app_order_record_order_id(&entry.record).as_deref() == Some(lookup) { + return load_app_order_record_from_record(config, entry.record).map(Some); + } + } + Ok(None) +} + +fn load_app_order_record_from_record( + config: &RuntimeConfig, + record: LocalEventRecord, +) -> Result<LoadedAppOrderRecord, RuntimeError> { + let mut source_issues = app_order_record_source_issues(config, &record)?; + let payload = record.local_work_json.clone().unwrap_or(Value::Null); + let document = match payload.get("document").cloned() { + Some(value) => match serde_json::from_value::<OrderDraftDocument>(value) { + Ok(document) => document, + Err(error) => { + source_issues.push(issue_with_code( + "invalid_app_order_record", + "document", + format!("app-authored order document cannot be decoded: {error}"), + )); + placeholder_app_order_document(&record) + } + }, + None => { + source_issues.push(issue_with_code( + "invalid_app_order_record", + "document", + "app-authored order record is missing document", + )); + placeholder_app_order_document(&record) + } + }; + Ok(LoadedAppOrderRecord { + loaded: LoadedOrderDraft { + file: PathBuf::from(format!("shared-local-events/{}", record.record_id)), + updated_at_unix: u64::try_from(record.updated_at_ms / 1000).unwrap_or_default(), + document, + }, + record, + source_issues, + }) +} + +fn app_order_record_source_issues( + config: &RuntimeConfig, + record: &LocalEventRecord, +) -> Result<Vec<OrderIssueView>, RuntimeError> { + let mut issues = Vec::new(); + if record.source_runtime != SourceRuntime::App { + issues.push(issue_with_code( + "app_order_unsupported", + "source_runtime", + "order record must come from radroots_app", + )); + } + if record.family != LocalRecordFamily::LocalWork { + issues.push(issue_with_code( + "app_order_unsupported", + "family", + "order record must be shared local work", + )); + } + if record.status != LocalRecordStatus::LocalSaved { + issues.push(issue_with_code( + "app_order_unsupported", + "status", + format!( + "order record status `{}` is not consumable as local saved work", + record.status.as_str() + ), + )); + } + let Some(payload) = record.local_work_json.as_ref() else { + issues.push(issue_with_code( + "invalid_app_order_record", + "local_work_json", + "app-authored order record is missing local work payload", + )); + return Ok(issues); + }; + let current = payload["currentness"]["current"].as_bool() == Some(true); + if !current { + issues.push(issue_with_code( + "app_order_stale", + "currentness.current", + "app-authored order record is not marked current", + )); + } + if current && let Err(error) = validate_buyer_order_request_local_work_payload(payload) { + issues.push(issue_with_code( + "invalid_app_order_record", + "local_work_json", + error.to_string(), + )); + } + if payload["support_status"]["state"].as_str() != Some("supported") { + issues.push(issue_with_code( + "app_order_unsupported", + "support_status.state", + "app-authored order record is not marked supported", + )); + } + if let Some(support_issues) = payload["support_status"]["issues"].as_array() { + for support_issue in support_issues { + if let Some(support_issue) = support_issue.as_str() { + issues.push(issue_with_code( + "app_order_unsupported", + "support_status.issues", + format!("app order support issue: {support_issue}"), + )); + } + } + } + if let Some(current_record) = current_app_order_record_for(config, record)? + && current_record.record_id != record.record_id + { + issues.push(issue_with_code( + "app_order_stale", + "record_id", + format!( + "app-authored local order record `{}` was superseded by `{}`", + record.record_id, current_record.record_id + ), + )); + } + Ok(issues) +} + +fn source_and_document_issues( + config: &RuntimeConfig, + app_order: &LoadedAppOrderRecord, +) -> Result<Vec<OrderIssueView>, RuntimeError> { + Ok(inspect_document_with_source_issues( + config, + &app_order.loaded.document, + app_order.source_issues.as_slice(), + )? + .issues) +} + +fn app_order_record_summary( + config: &RuntimeConfig, + record: &LocalEventRecord, + superseded_count: usize, +) -> Result<OrderAppRecordSummaryView, RuntimeError> { + let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned()); + let app_order = load_app_order_record_from_record(config, record.clone())?; + let issues = source_and_document_issues(config, &app_order)?; + let exportable = issues.is_empty(); + let reason = issues.first().map(|issue| issue.message.clone()); + let document = &app_order.loaded.document; + Ok(OrderAppRecordSummaryView { + record_id: record.record_id.clone(), + seq: record.seq, + change_seq: record.change_seq, + superseded_count, + record_kind, + status: record.status.as_str().to_owned(), + source_runtime: record.source_runtime.as_str().to_owned(), + owner_account_id: record.owner_account_id.clone(), + owner_pubkey: record.owner_pubkey.clone(), + farm_id: record.farm_id.clone(), + listing_addr: record + .listing_addr + .clone() + .or_else(|| non_empty_string(app_order.loaded.document.order.listing_addr.clone())), + order_id: non_empty_string(document.order.order_id.clone()), + buyer_account_id: buyer_account_id(document), + buyer_pubkey: non_empty_string(document.order.buyer_pubkey.clone()), + seller_pubkey: non_empty_string(document.order.seller_pubkey.clone()), + ready_for_submit: exportable, + exportable, + reason, + actions: if exportable { + vec![ + format!("radroots order get {}", document.order.order_id), + format!("radroots order app export {}", record.record_id), + format!( + "radroots --relay wss://relay.example.com order submit {}", + document.order.order_id + ), + ] + } else { + Vec::new() + }, + }) +} + +fn app_order_record_current_key(record: &LocalEventRecord) -> String { + app_order_record_order_id(record) + .map(|order_id| format!("order:{order_id}")) + .unwrap_or_else(|| format!("record:{}", record.record_id)) +} + +fn app_order_record_order_id(record: &LocalEventRecord) -> Option<String> { + record + .local_work_json + .as_ref() + .and_then(|payload| payload["document"]["order"]["order_id"].as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +fn placeholder_app_order_document(record: &LocalEventRecord) -> OrderDraftDocument { + OrderDraftDocument { + version: 0, + kind: "invalid_app_order_record".to_owned(), + order: OrderDraft { + order_id: app_order_record_order_id(record).unwrap_or_else(|| record.record_id.clone()), + listing_addr: String::new(), + listing_event_id: String::new(), + buyer_pubkey: String::new(), + seller_pubkey: String::new(), + items: Vec::new(), + economics: None, + }, + buyer_actor: OrderDraftBuyerActor { + account_id: String::new(), + pubkey: String::new(), + source: String::new(), + }, + listing_lookup: None, + } +} + +fn app_order_export_failure_state(issues: &[OrderIssueView]) -> &'static str { + if issues.iter().any(|issue| issue.code == "app_order_stale") { + "stale" + } else if issues.iter().any(|issue| { + issue.code == "app_order_unsupported" || issue.code == "invalid_app_order_record" + }) { + "unsupported" + } else { + "invalid" + } +} + +fn order_export_output_path( + config: &RuntimeConfig, + output: Option<&PathBuf>, + order_id: &str, +) -> PathBuf { + output + .cloned() + .unwrap_or_else(|| drafts_dir(config).join(format!("{order_id}.toml"))) +} + +fn validate_order_export_output_target(output_path: &Path) -> Result<(), RuntimeError> { + if output_path.exists() { + return Err(RuntimeError::Config(format!( + "order draft output {} must not already exist", + output_path.display() + ))); + } + if let Some(parent) = output_path.parent() { + if parent.exists() && !parent.is_dir() { + return Err(RuntimeError::Config(format!( + "order draft parent {} is not a directory", + parent.display() + ))); + } + } + Ok(()) +} + +fn local_record_kind(record: &LocalEventRecord) -> Option<String> { + record + .local_work_json + .as_ref() + .and_then(|payload| payload.get("record_kind")) + .and_then(Value::as_str) + .map(str::to_owned) +} + fn inspect_document( config: &RuntimeConfig, document: &OrderDraftDocument, ) -> Result<OrderInspection, RuntimeError> { + inspect_document_with_source_issues(config, document, &[]) +} + +fn inspect_document_with_source_issues( + config: &RuntimeConfig, + document: &OrderDraftDocument, + source_issues: &[OrderIssueView], +) -> Result<OrderInspection, RuntimeError> { let listing_addr = non_empty_string(document.order.listing_addr.clone()); let listing_event_id = non_empty_string(document.order.listing_event_id.clone()); let parsed_listing_addr = listing_addr @@ -9183,6 +9811,7 @@ fn inspect_document( let mut issues = collect_issues(document); let buyer_readiness = inspect_buyer_actor_readiness(config, document)?; issues.extend(buyer_readiness.issues); + issues.extend(source_issues.iter().cloned()); let ready_for_submit = issues.is_empty(); let state = if ready_for_submit { "ready".to_owned() @@ -9283,7 +9912,7 @@ fn collect_issues(document: &OrderDraftDocument) -> Vec<OrderIssueView> { if !is_valid_order_id(document.order.order_id.as_str()) { issues.push(issue( "order.order_id", - "order_id must look like `ord_<base64url>`", + "order_id must look like `ord_<base64url>` or a canonical UUID", )); } @@ -11153,10 +11782,26 @@ fn next_revision_id() -> String { } fn is_valid_order_id(value: &str) -> bool { - let Some(encoded) = value.strip_prefix("ord_") else { + if let Some(encoded) = value.strip_prefix("ord_") { + return encoded.len() == 22 && is_d_tag_base64url(encoded); + } + is_canonical_uuid(value) +} + +fn is_canonical_uuid(value: &str) -> bool { + if value.len() != 36 { return false; - }; - encoded.len() == 22 && is_d_tag_base64url(encoded) + } + for (index, character) in value.chars().enumerate() { + if matches!(index, 8 | 13 | 18 | 23) { + if character != '-' { + return false; + } + } else if !character.is_ascii_hexdigit() { + return false; + } + } + true } fn is_valid_event_id(value: &str) -> bool { diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -213,6 +213,12 @@ pub struct OrderSubmitArgs { } #[derive(Debug, Clone)] +pub struct OrderAppRecordExportArgs { + pub record_id: String, + pub output: Option<PathBuf>, +} + +#[derive(Debug, Clone)] pub struct OrderRebindArgs { pub key: String, pub selector: String, diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -226,6 +226,10 @@ impl TargetCommand { OrderCommand::Submit(_) => "order.submit", OrderCommand::Get(_) => "order.get", OrderCommand::List => "order.list", + OrderCommand::App(app) => match &app.command { + OrderAppCommand::List => "order.app.list", + OrderAppCommand::Export(_) => "order.app.export", + }, OrderCommand::Rebind(_) => "order.rebind", OrderCommand::Accept(_) => "order.accept", OrderCommand::Decline(_) => "order.decline", @@ -855,6 +859,7 @@ pub enum OrderCommand { Submit(OrderSubmitArgs), Get(OrderKeyArgs), List, + App(OrderAppArgs), Rebind(OrderRebindArgs), Accept(OrderKeyArgs), Decline(OrderDeclineArgs), @@ -879,6 +884,25 @@ pub struct OrderKeyArgs { } #[derive(Debug, Clone, Args)] +pub struct OrderAppArgs { + #[command(subcommand)] + pub command: OrderAppCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum OrderAppCommand { + List, + Export(OrderAppExportArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct OrderAppExportArgs { + pub record_id: Option<String>, + #[arg(long)] + pub output: Option<PathBuf>, +} + +#[derive(Debug, Clone, Args)] pub struct OrderRebindArgs { pub order_id: Option<String>, pub selector: Option<String>, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -14,8 +14,8 @@ use radroots_events::trade::{ }; use radroots_events_codec::trade::active_trade_order_request_event_build; use radroots_local_events::{ - LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, LocalRecordStatus, - PublishOutboxStatus, SourceRuntime, + BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecordInput, LocalEventsStore, + LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, SourceRuntime, }; use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event}; use radroots_replica_db::{farm, farm_member_claim, migrations}; @@ -478,6 +478,179 @@ fn append_app_local_record(input: LocalEventRecordInput, sandbox: &RadrootsCliSa .expect("append app local event record"); } +fn seed_app_order_record( + sandbox: &RadrootsCliSandbox, + account_id: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + order_id: &str, + listing_addr: &str, + listing_event_id: &str, +) -> String { + seed_app_order_record_variant( + sandbox, + account_id, + buyer_pubkey, + seller_pubkey, + order_id, + listing_addr, + listing_event_id, + true, + "supported", + Vec::new(), + ) +} + +fn seed_app_order_record_variant( + sandbox: &RadrootsCliSandbox, + account_id: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + order_id: &str, + listing_addr: &str, + listing_event_id: &str, + current: bool, + support_state: &str, + support_issues: Vec<&str>, +) -> String { + let record_id = format!("app:local_work:order_request:{order_id}"); + let support_issues = support_issues + .into_iter() + .map(|issue| Value::String(issue.to_owned())) + .collect::<Vec<_>>(); + let payload = json!({ + "record_kind": BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, + "scope": "app", + "exportability": { + "state": "exportable", + }, + "support_status": { + "state": support_state, + "issues": support_issues, + }, + "currentness": { + "current": current, + "source": "app_sqlite_order", + "record_id": record_id, + "order_id": order_id, + "order_updated_at": 1_779_000_010, + "created_at_ms": 1_779_000_010_000_i64, + }, + "no_payment": { + "payment_required": false, + "settlement_deferred": true, + "payment_state": "not_applicable", + }, + "document": { + "version": 1, + "kind": "order_draft_v1", + "order": { + "order_id": order_id, + "listing_addr": listing_addr, + "listing_event_id": listing_event_id, + "buyer_pubkey": buyer_pubkey, + "seller_pubkey": seller_pubkey, + "items": [ + { + "bin_id": "bin-1", + "bin_count": 2, + } + ], + "economics": { + "quote_id": format!("app-order:{order_id}"), + "quote_version": 1, + "pricing_basis": "listing_event", + "currency": "USD", + "items": [ + { + "bin_id": "bin-1", + "bin_count": 2, + "quantity_amount": "1", + "quantity_unit": "each", + "unit_price_amount": "6", + "unit_price_currency": "USD", + "line_subtotal": { + "amount": "12", + "currency": "USD", + }, + } + ], + "discounts": [], + "adjustments": [], + "subtotal": { + "amount": "12", + "currency": "USD", + }, + "discount_total": { + "amount": "0", + "currency": "USD", + }, + "adjustment_total": { + "amount": "0", + "currency": "USD", + }, + "total": { + "amount": "12", + "currency": "USD", + }, + }, + }, + "buyer_actor": { + "account_id": account_id, + "pubkey": buyer_pubkey, + "source": "resolved_account", + }, + "listing_lookup": listing_addr, + }, + "app_order": { + "order_id": order_id, + "order_number": 1, + "farm_id": "018f47a8-7b2c-7000-8000-0000000000f1", + "farm_display_name": "CLI Interop Farm", + "farm_key": "pasture-eggs", + "status": "placed", + "buyer_context_key": "buyer_context", + "lines": [ + { + "line_id": format!("{order_id}:product-eggs"), + "product_id": "product-eggs", + "listing_addr": listing_addr, + "listing_event_id": listing_event_id, + "seller_pubkey": seller_pubkey, + } + ], + }, + }); + append_app_local_record( + LocalEventRecordInput { + record_id: record_id.clone(), + family: LocalRecordFamily::LocalWork, + status: LocalRecordStatus::LocalSaved, + source_runtime: SourceRuntime::App, + created_at_ms: 1_779_000_010_000, + inserted_at_ms: 1_779_000_010_000, + owner_account_id: Some(account_id.to_owned()), + owner_pubkey: Some(buyer_pubkey.to_owned()), + farm_id: Some("018f47a8-7b2c-7000-8000-0000000000f1".to_owned()), + listing_addr: Some(listing_addr.to_owned()), + local_work_json: Some(payload), + event_id: None, + event_kind: None, + event_pubkey: None, + event_created_at: None, + event_tags_json: None, + event_content: None, + event_sig: None, + raw_event_json: None, + outbox_status: PublishOutboxStatus::None, + relay_set_fingerprint: None, + relay_delivery_json: None, + }, + sandbox, + ); + record_id +} + #[test] fn root_help_exposes_only_target_namespaces() { let output = radroots().arg("--help").output().expect("run root help"); @@ -4134,6 +4307,207 @@ fn listing_app_records_export_uses_record_owner_over_body_pubkey() { } #[test] +fn order_app_records_list_export_get_and_submit_supported_app_order() { + let sandbox = RadrootsCliSandbox::new(); + let account = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = account["result"]["account"]["id"] + .as_str() + .expect("account id"); + let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + let buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] + .as_str() + .expect("buyer pubkey"); + let seller_pubkey = identity_public(73).public_key_hex; + let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; + let listing_addr = format!("30402:{seller_pubkey}:{listing_d_tag}"); + let listing_event_id = seed_orderable_listing(&sandbox, listing_addr.as_str()); + let order_id = "018f47a8-7b2c-7000-8000-000000000011"; + let record_id = seed_app_order_record( + &sandbox, + account_id, + buyer_pubkey, + seller_pubkey.as_str(), + order_id, + listing_addr.as_str(), + listing_event_id.as_str(), + ); + + let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); + assert_eq!(app_list["operation_id"], "order.app.list"); + assert_eq!(app_list["result"]["state"], "ready"); + assert_eq!(app_list["result"]["count"], 1); + assert_eq!(app_list["result"]["records"][0]["record_id"], record_id); + assert_eq!(app_list["result"]["records"][0]["order_id"], order_id); + assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], true); + assert_eq!(app_list["result"]["records"][0]["exportable"], true); + assert_no_removed_command_reference(&app_list, &["order", "app", "list"]); + assert_no_daemon_runtime_reference(&app_list, &["order", "app", "list"]); + + let orders = sandbox.json_success(&["--format", "json", "order", "list"]); + assert_eq!(orders["operation_id"], "order.list"); + assert_eq!(orders["result"]["state"], "ready"); + assert_eq!(orders["result"]["count"], 1); + assert_eq!(orders["result"]["orders"][0]["id"], order_id); + assert_eq!(orders["result"]["orders"][0]["ready_for_submit"], true); + assert_eq!( + orders["result"]["orders"][0]["listing_event_id"], + listing_event_id + ); + assert_eq!( + orders["result"]["orders"][0]["buyer_account_id"], + account_id + ); + assert_eq!( + orders["result"]["orders"][0]["file"], + format!("shared-local-events/{record_id}") + ); + assert_no_removed_command_reference(&orders, &["order", "list"]); + assert_no_daemon_runtime_reference(&orders, &["order", "list"]); + + let get_by_record = + sandbox.json_success(&["--format", "json", "order", "get", record_id.as_str()]); + assert_eq!(get_by_record["operation_id"], "order.get"); + assert_eq!(get_by_record["result"]["state"], "ready"); + assert_eq!(get_by_record["result"]["order_id"], order_id); + assert_eq!(get_by_record["result"]["ready_for_submit"], true); + + let export_path = sandbox.root().join("app-order.toml"); + let export_path_arg = export_path.to_string_lossy(); + let dry_run = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "order", + "app", + "export", + record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert_eq!(dry_run["operation_id"], "order.app.export"); + assert_eq!(dry_run["result"]["state"], "dry_run"); + assert_eq!(dry_run["result"]["valid"], true); + assert!(!export_path.exists()); + + let export = sandbox.json_success(&[ + "--format", + "json", + "order", + "app", + "export", + record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert_eq!(export["operation_id"], "order.app.export"); + assert_eq!(export["result"]["state"], "exported"); + assert_eq!(export["result"]["order_id"], order_id); + assert!(export_path.exists()); + let exported_contents = fs::read_to_string(&export_path).expect("exported order draft"); + assert!(exported_contents.contains("kind = \"order_draft_v1\"")); + assert!(exported_contents.contains(format!("order_id = \"{order_id}\"").as_str())); + assert!(exported_contents.contains("source = \"resolved_account\"")); + + let (dry_output, submit) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "order", + "submit", + record_id.as_str(), + ]); + assert!(!dry_output.status.success()); + assert_eq!(dry_output.status.code(), Some(8)); + assert_eq!(submit["operation_id"], "order.submit"); + assert_eq!(submit["errors"][0]["code"], "network_unavailable"); + assert!( + submit["errors"][0]["message"] + .as_str() + .expect("submit message") + .contains("order submit requires at least one configured relay") + ); +} + +#[test] +fn order_app_records_fail_closed_when_not_current_or_supported() { + let sandbox = RadrootsCliSandbox::new(); + let account = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = account["result"]["account"]["id"] + .as_str() + .expect("account id"); + let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + let buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] + .as_str() + .expect("buyer pubkey"); + let seller_pubkey = identity_public(74).public_key_hex; + let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ"); + let listing_event_id = "2".repeat(64); + let stale_order_id = "018f47a8-7b2c-7000-8000-000000000012"; + let stale_record_id = seed_app_order_record_variant( + &sandbox, + account_id, + buyer_pubkey, + seller_pubkey.as_str(), + stale_order_id, + listing_addr.as_str(), + listing_event_id.as_str(), + false, + "supported", + Vec::new(), + ); + + let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); + assert_eq!( + app_list["result"]["records"][0]["record_id"], + stale_record_id + ); + assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], false); + assert_eq!(app_list["result"]["records"][0]["exportable"], false); + assert!( + app_list["result"]["records"][0]["reason"] + .as_str() + .expect("stale reason") + .contains("not marked current") + ); + + let export_path = sandbox.root().join("stale-app-order.toml"); + let export_path_arg = export_path.to_string_lossy(); + let (output, stale_export) = sandbox.json_output(&[ + "--format", + "json", + "order", + "app", + "export", + stale_record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert!(!output.status.success()); + assert_eq!(stale_export["operation_id"], "order.app.export"); + assert_eq!(stale_export["result"], Value::Null); + assert_eq!(stale_export["errors"][0]["detail"]["state"], "stale"); + assert_eq!(stale_export["errors"][0]["detail"]["valid"], false); + assert!(!export_path.exists()); + + let (submit_output, submit) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "order", + "submit", + stale_record_id.as_str(), + ]); + assert!(!submit_output.status.success()); + assert_eq!(submit_output.status.code(), Some(3)); + assert_eq!(submit["operation_id"], "order.submit"); + assert_eq!(submit["errors"][0]["code"], "operation_unavailable"); + assert_eq!( + submit["errors"][0]["detail"]["issues"][0]["code"], + "app_order_stale" + ); +} + +#[test] fn farm_publish_writes_acknowledged_signed_outbox_records() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]);