cli

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

commit 122e61eced234feccd4cb7fee0686189e6adef07
parent 06fecf0f298c71d9ddad5945bf8558c2b22241f3
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Apr 2026 21:57:39 +0000

order: add fulfillment update cli surface

Diffstat:
Msrc/domain/runtime.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 3+++
Msrc/operation_adapter.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/operation_order.rs | 37++++++++++++++++++++++++++++++++++---
Msrc/operation_registry.rs | 20+++++++++++++++++++-
Msrc/runtime/order.rs | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/target_cli.rs | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/target_cli.rs | 15+++++++++++++++
8 files changed, 370 insertions(+), 8 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1274,6 +1274,71 @@ impl OrderDecisionView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderFulfillmentView { + pub state: String, + pub source: String, + pub order_id: String, + pub fulfillment_state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: 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>, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub root_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_kind: Option<u32>, + #[serde(default)] + pub dry_run: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub connected_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub acknowledged_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failed_relays: Vec<RelayFailureView>, + #[serde(default)] + pub fetched_count: usize, + #[serde(default)] + pub decoded_count: usize, + #[serde(default)] + pub skipped_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_mode: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<OrderIssueView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl OrderFulfillmentView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "missing" => CommandDisposition::NotFound, + "invalid" | "requested" | "declined" | "forked" => CommandDisposition::ValidationFailed, + "unconfigured" => CommandDisposition::Unconfigured, + "unavailable" => CommandDisposition::ExternalUnavailable, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct OrderStatusView { pub state: String, pub source: String, diff --git a/src/main.rs b/src/main.rs @@ -266,6 +266,9 @@ fn execute_request( TargetOperationRequest::OrderDecline(request) => { execute_with(OrderOperationService::new(config), request) } + TargetOperationRequest::OrderFulfillmentUpdate(request) => { + execute_with(OrderOperationService::new(config), request) + } TargetOperationRequest::OrderStatusGet(request) => { execute_with(OrderOperationService::new(config), request) } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1054,7 +1054,8 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati AccountCommand, AccountSelectionCommand, BasketCommand, BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand, FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand, - MarketProductCommand, OrderCommand, OrderEventCommand, OrderStatusCommand, TargetCommand, + MarketProductCommand, OrderCommand, OrderEventCommand, OrderFulfillmentCommand, + OrderStatusCommand, TargetCommand, }; let mut input = OperationData::new(); @@ -1187,6 +1188,17 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "order_id", &args.order_id); insert_string(&mut input, "reason", &args.reason); } + OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command { + OrderFulfillmentCommand::Update(args) => { + insert_string(&mut input, "order_id", &args.order_id); + if let Some(state) = args.state { + input.insert( + "state".to_owned(), + Value::String(state.as_protocol_state().to_owned()), + ); + } + } + }, OrderCommand::Status(status) => match &status.command { OrderStatusCommand::Get(args) => { insert_string(&mut input, "order_id", &args.order_id) @@ -1290,6 +1302,7 @@ target_operation_contracts! { OrderList => (OrderListRequest, OrderListResult, "order.list"), OrderAccept => (OrderAcceptRequest, OrderAcceptResult, "order.accept"), OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"), + OrderFulfillmentUpdate => (OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, "order.fulfillment.update"), OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"), OrderEventList => (OrderEventListRequest, OrderEventListResult, "order.event.list"), OrderEventWatch => (OrderEventWatchRequest, OrderEventWatchResult, "order.event.watch"), @@ -1309,7 +1322,7 @@ mod tests { use std::io; use clap::Parser; - use serde_json::json; + use serde_json::{Value, json}; use super::{ OperationAdapter, OperationAdapterError, OperationContext, OperationInputMode, @@ -1396,6 +1409,40 @@ mod tests { } #[test] + fn adapter_maps_order_fulfillment_update_input() { + let parsed = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "fulfillment", + "update", + "ord_test", + "--state", + "seller_cancelled", + ]) + .expect("target args parse"); + + let request = TargetOperationRequest::from_target_args(&parsed) + .expect("operation request from target args"); + let TargetOperationRequest::OrderFulfillmentUpdate(request) = request else { + panic!("expected order fulfillment update request") + }; + + assert_eq!(request.operation_id(), "order.fulfillment.update"); + assert_eq!( + request + .payload + .input + .get("order_id") + .and_then(Value::as_str), + Some("ord_test") + ); + assert_eq!( + request.payload.input.get("state").and_then(Value::as_str), + Some("seller_cancelled") + ); + } + + #[test] fn typed_service_boundary_returns_enveloped_result() { struct WorkspaceService; diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -8,9 +8,10 @@ use crate::operation_adapter::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, OperationResult, OperationResultData, OperationService, OrderAcceptRequest, OrderAcceptResult, OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventListResult, - OrderEventWatchRequest, OrderEventWatchResult, OrderGetRequest, OrderGetResult, - OrderListRequest, OrderListResult, OrderStatusGetRequest, OrderStatusGetResult, - OrderSubmitRequest, OrderSubmitResult, + OrderEventWatchRequest, OrderEventWatchResult, OrderFulfillmentUpdateRequest, + OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult, OrderListRequest, + OrderListResult, OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, + OrderSubmitResult, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; @@ -166,6 +167,36 @@ impl OperationService<OrderDeclineRequest> for OrderOperationService<'_> { } } +impl OperationService<OrderFulfillmentUpdateRequest> for OrderOperationService<'_> { + type Result = OrderFulfillmentUpdateResult; + + fn execute( + &self, + request: OperationRequest<OrderFulfillmentUpdateRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + required_order_key(&request)?; + string_input(&request, "state") + .map(|state| state.trim().to_owned()) + .filter(|state| !state.is_empty()) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + "missing required `state` input".to_owned(), + ) + })?; + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + Err(OperationAdapterError::from_command_disposition( + request.operation_id(), + CommandDisposition::Unsupported, + "order fulfillment update runtime is not wired yet".to_owned(), + )) + } +} + impl OperationService<OrderStatusGetRequest> for OrderOperationService<'_> { type Result = OrderStatusGetResult; diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -872,6 +872,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ true ), operation!( + "order.fulfillment.update", + "radroots order fulfillment update", + "order", + "order_fulfillment_update", + "OrderFulfillmentUpdateRequest", + "OrderFulfillmentUpdateResult", + "Update seller-authored order fulfillment state.", + Seller, + true, + Required, + High, + false, + true + ), + operation!( "order.status.get", "radroots order status get", "order", @@ -993,6 +1008,7 @@ mod tests { "order.list", "order.accept", "order.decline", + "order.fulfillment.update", "order.status.get", "order.event.list", "order.event.watch", @@ -1027,6 +1043,7 @@ mod tests { "order.submit", "order.accept", "order.decline", + "order.fulfillment.update", ]; const INTENTIONALLY_UNSUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[]; @@ -1039,7 +1056,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 56); + assert_eq!(OPERATION_REGISTRY.len(), 57); } #[test] @@ -1088,6 +1105,7 @@ mod tests { "order.submit", "order.accept", "order.decline", + "order.fulfillment.update", ] .into_iter() .collect::<BTreeSet<_>>(); diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -986,7 +986,7 @@ fn order_status_from_receipt_with_context( }); let order_id = context.order_id; - let projection = reduce_active_order_events(order_id, requests, decisions.clone()); + let projection = reduce_active_order_events(order_id, requests, decisions.clone(), []); let listing_event_id = projection .request_event_id .as_ref() @@ -1103,6 +1103,7 @@ fn enrich_order_status_inventory( listing.bins, requests, decisions, + [], ); let relevant_issues = projection .issues @@ -1587,6 +1588,114 @@ fn active_order_reducer_issue_view(issue_value: RadrootsActiveOrderReducerIssue) "active order reducer reported conflicting decisions", event_ids, ), + RadrootsActiveOrderReducerIssue::FulfillmentWithoutAcceptedDecision { event_id } => { + issue_with_events( + "fulfillment_without_accepted_decision", + "fulfillment_event_id", + "active order reducer reported fulfillment without accepted decision", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentPayloadInvalid { event_id } => { + issue_with_events( + "invalid_fulfillment_payload", + "fulfillment_payload", + "active order reducer reported invalid fulfillment payload", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentOrderIdMismatch { event_id } => { + issue_with_events( + "fulfillment_order_id_mismatch", + "order_id", + "active order reducer reported fulfillment order id mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentAuthorMismatch { event_id } => { + issue_with_events( + "fulfillment_author_mismatch", + "seller_pubkey", + "active order reducer reported fulfillment author mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentCounterpartyMismatch { event_id } => { + issue_with_events( + "fulfillment_counterparty_mismatch", + "buyer_pubkey", + "active order reducer reported fulfillment counterparty mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentBuyerMismatch { event_id } => { + issue_with_events( + "fulfillment_buyer_mismatch", + "buyer_pubkey", + "active order reducer reported fulfillment buyer mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentSellerMismatch { event_id } => { + issue_with_events( + "fulfillment_seller_mismatch", + "seller_pubkey", + "active order reducer reported fulfillment seller mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentListingAddressInvalid { event_id } => { + issue_with_events( + "invalid_fulfillment_listing_address", + "listing_addr", + "active order reducer reported invalid fulfillment listing address", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentListingMismatch { event_id } => { + issue_with_events( + "fulfillment_listing_mismatch", + "listing_addr", + "active order reducer reported fulfillment listing mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentRootMismatch { event_id } => issue_with_events( + "fulfillment_root_mismatch", + "root_event_id", + "active order reducer reported fulfillment root mismatch", + vec![event_id], + ), + RadrootsActiveOrderReducerIssue::FulfillmentPreviousMismatch { event_id } => { + issue_with_events( + "fulfillment_previous_mismatch", + "prev_event_id", + "active order reducer reported fulfillment previous mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentStatusNotPublishable { event_id } => { + issue_with_events( + "fulfillment_status_not_publishable", + "fulfillment_state", + "active order reducer reported non-publishable fulfillment status", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::FulfillmentUnsupportedTransition { event_id } => { + issue_with_events( + "fulfillment_unsupported_transition", + "fulfillment_state", + "active order reducer reported unsupported fulfillment transition", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::ForkedFulfillments { event_ids } => issue_with_events( + "forked_fulfillments", + "fulfillment_event_id", + "active order reducer reported forked fulfillment updates", + event_ids, + ), } } @@ -1980,6 +2089,7 @@ fn order_accept_inventory_preflight_view( listing.bins, requests, decisions, + [], ); Ok(order_accept_inventory_preflight_view_from_projection( config, args, request, resolution, status, projection, @@ -5386,6 +5496,7 @@ mod tests { }, proposed_accept_decision_record(&request).expect("proposed accept decision"), ], + [], ); let args = OrderDecisionArgs { key: fixture.order_id.clone(), diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -174,6 +174,9 @@ impl TargetCommand { OrderCommand::List => "order.list", OrderCommand::Accept(_) => "order.accept", OrderCommand::Decline(_) => "order.decline", + OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command { + OrderFulfillmentCommand::Update(_) => "order.fulfillment.update", + }, OrderCommand::Status(status) => match &status.command { OrderStatusCommand::Get(_) => "order.status.get", }, @@ -685,6 +688,7 @@ pub enum OrderCommand { List, Accept(OrderKeyArgs), Decline(OrderDeclineArgs), + Fulfillment(OrderFulfillmentArgs), Status(OrderStatusArgs), Event(OrderEventArgs), } @@ -707,6 +711,46 @@ pub struct OrderDeclineArgs { } #[derive(Debug, Clone, Args)] +pub struct OrderFulfillmentArgs { + #[command(subcommand)] + pub command: OrderFulfillmentCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum OrderFulfillmentCommand { + Update(OrderFulfillmentUpdateArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct OrderFulfillmentUpdateArgs { + pub order_id: Option<String>, + #[arg(long, value_enum)] + pub state: Option<OrderFulfillmentStateArg>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "snake_case")] +pub enum OrderFulfillmentStateArg { + Preparing, + ReadyForPickup, + OutForDelivery, + Delivered, + SellerCancelled, +} + +impl OrderFulfillmentStateArg { + pub const fn as_protocol_state(self) -> &'static str { + match self { + Self::Preparing => "preparing", + Self::ReadyForPickup => "ready_for_pickup", + Self::OutForDelivery => "out_for_delivery", + Self::Delivered => "delivered", + Self::SellerCancelled => "seller_cancelled", + } + } +} + +#[derive(Debug, Clone, Args)] pub struct OrderStatusArgs { #[command(subcommand)] pub command: OrderStatusCommand, @@ -741,7 +785,10 @@ mod tests { use clap::{CommandFactory, Parser}; - use super::{TargetCliArgs, TargetOutputFormat}; + use super::{ + OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, TargetCliArgs, + TargetOutputFormat, + }; use crate::operation_registry::OPERATION_REGISTRY; #[test] @@ -829,6 +876,31 @@ mod tests { } #[test] + fn target_parser_accepts_order_fulfillment_update_state() { + let parsed = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "fulfillment", + "update", + "ord_test", + "--state", + "ready_for_pickup", + ]) + .expect("target args parse"); + + assert_eq!(parsed.command.operation_id(), "order.fulfillment.update"); + let crate::target_cli::TargetCommand::Order(order) = parsed.command else { + panic!("expected order command") + }; + let OrderCommand::Fulfillment(fulfillment) = order.command else { + panic!("expected order fulfillment command") + }; + let OrderFulfillmentCommand::Update(args) = fulfillment.command; + assert_eq!(args.order_id.as_deref(), Some("ord_test")); + assert_eq!(args.state, Some(OrderFulfillmentStateArg::ReadyForPickup)); + } + + #[test] fn target_parser_rejects_removed_global_flags() { let rejected = [ vec!["radroots", "--output", "json", "config", "get"], diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -143,6 +143,21 @@ fn seller_order_decision_and_status_commands_are_public() { "order.status.get", ["--format", "json", "order", "status", "get", "ord_public"].as_slice(), ), + ( + "order.fulfillment.update", + [ + "--format", + "json", + "--dry-run", + "order", + "fulfillment", + "update", + "ord_public", + "--state", + "ready_for_pickup", + ] + .as_slice(), + ), ] { let output = radroots() .args(args)