cli

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

commit 050f20d8ceae72d64cf8aff912a51624d6c05131
parent be4c11b73627c1f7f5509fdf81c27c3340e7e9a8
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 03:09:47 +0000

cli: back order operations with adapters

- add the order operation service for submit, get, list, event list, and event watch
- route target order operations through the existing order runtime and job boundary
- require approval for non-dry-run order submit and normalize legacy order actions
- cover order missing, approval, history, and event watch behavior with focused tests

Diffstat:
Msrc/main.rs | 1+
Asrc/operation_order.rs | 463+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 464 insertions(+), 0 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -9,6 +9,7 @@ mod operation_core; mod operation_farm; mod operation_listing; mod operation_market; +mod operation_order; mod operation_registry; mod operation_runtime; mod output_contract; diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -0,0 +1,463 @@ +#![allow(dead_code)] + +use serde::Serialize; +use serde_json::Value; + +use crate::cli::{OrderSubmitArgs, OrderWatchArgs, RecordKeyArgs}; +use crate::operation_adapter::{ + OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, + OperationResult, OperationResultData, OperationService, OrderEventListRequest, + OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult, OrderGetRequest, + OrderGetResult, OrderListRequest, OrderListResult, OrderSubmitRequest, OrderSubmitResult, +}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +pub struct OrderOperationService<'a> { + config: &'a RuntimeConfig, +} + +impl<'a> OrderOperationService<'a> { + pub fn new(config: &'a RuntimeConfig) -> Self { + Self { config } + } +} + +impl OperationService<OrderSubmitRequest> for OrderOperationService<'_> { + type Result = OrderSubmitResult; + + fn execute( + &self, + request: OperationRequest<OrderSubmitRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + if !request.context.dry_run && request.context.approval_token.is_none() { + return Err(OperationAdapterError::InvalidInput { + operation_id: request.operation_id().to_owned(), + message: "missing required `approval_token` input".to_owned(), + }); + } + + let key = required_order_key(&request)?; + let args = OrderSubmitArgs { + key, + watch: bool_input(&request, "watch").unwrap_or(false), + idempotency_key: request + .context + .idempotency_key + .clone() + .or_else(|| string_input(&request, "idempotency_key")), + signer_session_id: request + .context + .signer_session_id + .clone() + .or_else(|| string_input(&request, "signer_session_id")), + }; + let mut config = self.config.clone(); + if request.context.dry_run { + config.output.dry_run = true; + } + let view = map_runtime(crate::runtime::order::submit(&config, &args))?; + serialized_target_result::<OrderSubmitResult, _>(&view) + } +} + +impl OperationService<OrderGetRequest> for OrderOperationService<'_> { + type Result = OrderGetResult; + + fn execute( + &self, + request: OperationRequest<OrderGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = RecordKeyArgs { + key: required_order_key(&request)?, + }; + let view = map_runtime(crate::runtime::order::get(self.config, &args))?; + serialized_target_result::<OrderGetResult, _>(&view) + } +} + +impl OperationService<OrderListRequest> for OrderOperationService<'_> { + type Result = OrderListResult; + + fn execute( + &self, + _request: OperationRequest<OrderListRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = map_runtime(crate::runtime::order::list(self.config))?; + serialized_target_result::<OrderListResult, _>(&view) + } +} + +impl OperationService<OrderEventListRequest> for OrderOperationService<'_> { + type Result = OrderEventListResult; + + fn execute( + &self, + _request: OperationRequest<OrderEventListRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = map_runtime(crate::runtime::order::history(self.config))?; + serialized_target_result::<OrderEventListResult, _>(&view) + } +} + +impl OperationService<OrderEventWatchRequest> for OrderOperationService<'_> { + type Result = OrderEventWatchResult; + + fn execute( + &self, + request: OperationRequest<OrderEventWatchRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = OrderWatchArgs { + key: required_order_key(&request)?, + frames: usize_input(&request, "frames").or(Some(1)), + interval_ms: u64_input(&request, "interval_ms").unwrap_or(1_000), + }; + let view = map_runtime(crate::runtime::order::watch(self.config, &args))?; + serialized_target_result::<OrderEventWatchResult, _>(&view) + } +} + +fn serialized_target_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, + T: Serialize, +{ + let mut value = serde_json::to_value(value) + .map_err(|error| OperationAdapterError::Serialization(error.to_string()))?; + translate_actions_in_value(&mut value); + OperationResult::new(R::from_value(value)) +} + +fn translate_actions_in_value(value: &mut Value) { + match value { + Value::Object(object) => { + if let Some(Value::Array(actions)) = object.get_mut("actions") { + for action in actions { + if let Value::String(action) = action { + *action = target_action(action); + } + } + } + for nested in object.values_mut() { + translate_actions_in_value(nested); + } + } + Value::Array(values) => { + for nested in values { + translate_actions_in_value(nested); + } + } + _ => {} + } +} + +fn target_action(action: &str) -> String { + match action { + "radroots order ls" => "radroots order list".to_owned(), + "radroots order new" | "radroots order create" => "radroots basket create".to_owned(), + "radroots order history" => "radroots order event list".to_owned(), + "radroots rpc status" => "radroots runtime status get".to_owned(), + other if other.starts_with("radroots order watch ") => { + other.replacen("radroots order watch ", "radroots order event watch ", 1) + } + other => other.to_owned(), + } +} + +fn required_order_key<P>(request: &OperationRequest<P>) -> Result<String, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + string_input(request, "order_id") + .or_else(|| string_input(request, "key")) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + "missing required `order_id` input".to_owned(), + ) + }) +} + +fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> +where + P: OperationRequestPayload + OperationRequestData, +{ + request + .payload + .input() + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) +} + +fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool> +where + P: OperationRequestPayload + OperationRequestData, +{ + request.payload.input().get(key).and_then(Value::as_bool) +} + +fn usize_input<P>(request: &OperationRequest<P>, key: &str) -> Option<usize> +where + P: OperationRequestPayload + OperationRequestData, +{ + request + .payload + .input() + .get(key) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) +} + +fn u64_input<P>(request: &OperationRequest<P>, key: &str) -> Option<u64> +where + P: OperationRequestPayload + OperationRequestData, +{ + request.payload.input().get(key).and_then(Value::as_u64) +} + +fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { + result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) +} + +fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { + OperationAdapterError::InvalidInput { + operation_id: operation_id.to_owned(), + message, + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use radroots_runtime_paths::RadrootsMigrationReport; + use radroots_secret_vault::RadrootsSecretBackend; + use serde_json::{Map, Value}; + use tempfile::tempdir; + + use super::OrderOperationService; + use crate::operation_adapter::{ + OperationAdapter, OperationContext, OperationData, OperationRequest, OrderEventListRequest, + OrderEventWatchRequest, OrderGetRequest, OrderListRequest, OrderSubmitRequest, + }; + use crate::runtime::config::{ + AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, + LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, + PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, + SignerBackend, SignerConfig, Verbosity, + }; + + #[test] + fn order_service_get_and_list_preserve_order_truth() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let get = OperationRequest::new( + OperationContext::default(), + OrderGetRequest::from_data(data(&[("order_id", "ord_missing")])), + ) + .expect("order get request"); + let get_envelope = service + .execute(get) + .expect("order get result") + .to_envelope(OperationContext::default().envelope_context("req_order_get")) + .expect("order get envelope"); + + assert_eq!(get_envelope.operation_id, "order.get"); + assert_eq!(get_envelope.result["state"], "missing"); + assert_eq!(get_envelope.result["actions"][0], "radroots order list"); + assert_eq!(get_envelope.result["actions"][1], "radroots basket create"); + + let list = OperationRequest::new(OperationContext::default(), OrderListRequest::default()) + .expect("order list request"); + let list_envelope = service + .execute(list) + .expect("order list result") + .to_envelope(OperationContext::default().envelope_context("req_order_list")) + .expect("order list envelope"); + assert_eq!(list_envelope.operation_id, "order.list"); + assert_eq!(list_envelope.result["state"], "empty"); + assert_eq!(list_envelope.result["actions"][0], "radroots basket create"); + } + + #[test] + fn order_submit_requires_approval_token() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let submit = OperationRequest::new( + OperationContext::default(), + OrderSubmitRequest::from_data(data(&[("order_id", "ord_missing")])), + ) + .expect("order submit request"); + let error = service.execute(submit).expect_err("approval required"); + + assert!(format!("{error}").contains("approval_token")); + } + + #[test] + fn order_submit_with_approval_preserves_missing_order_truth() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let mut context = OperationContext::default(); + context.approval_token = Some("approve_test".to_owned()); + let submit = OperationRequest::new( + context.clone(), + OrderSubmitRequest::from_data(data(&[("order_id", "ord_missing")])), + ) + .expect("order submit request"); + let envelope = service + .execute(submit) + .expect("order submit result") + .to_envelope(context.envelope_context("req_order_submit")) + .expect("order submit envelope"); + + assert_eq!(envelope.operation_id, "order.submit"); + assert_eq!(envelope.result["state"], "missing"); + assert_eq!(envelope.result["actions"][0], "radroots order list"); + } + + #[test] + fn order_event_list_wraps_history_without_legacy_action() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let request = OperationRequest::new( + OperationContext::default(), + OrderEventListRequest::default(), + ) + .expect("order event list request"); + let envelope = service + .execute(request) + .expect("order event list result") + .to_envelope(OperationContext::default().envelope_context("req_order_events")) + .expect("order event list envelope"); + + assert_eq!(envelope.operation_id, "order.event.list"); + assert_eq!(envelope.result["state"], "empty"); + assert_eq!(envelope.result["actions"][0], "radroots order list"); + } + + #[test] + fn order_event_watch_reports_missing_order_with_target_actions() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let request = OperationRequest::new( + OperationContext::default(), + OrderEventWatchRequest::from_data(data(&[("order_id", "ord_missing")])), + ) + .expect("order event watch request"); + let envelope = service + .execute(request) + .expect("order event watch result") + .to_envelope(OperationContext::default().envelope_context("req_order_watch")) + .expect("order event watch envelope"); + + assert_eq!(envelope.operation_id, "order.event.watch"); + assert_eq!(envelope.result["state"], "missing"); + assert_eq!(envelope.result["actions"][0], "radroots order list"); + } + + fn sample_config(root: &Path) -> RuntimeConfig { + let data = root.join("data"); + let logs = root.join("logs"); + let secrets = root.join("secrets"); + RuntimeConfig { + output: OutputConfig { + format: OutputFormat::Human, + verbosity: Verbosity::Normal, + color: true, + dry_run: false, + }, + interaction: InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: false, + stdout_tty: false, + prompts_allowed: false, + confirmations_allowed: false, + }, + paths: PathsConfig { + profile: "interactive_user".into(), + profile_source: "test".into(), + allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], + root_source: "test".into(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".into(), + app_namespace: "apps/cli".into(), + shared_accounts_namespace: "shared/accounts".into(), + shared_identities_namespace: "shared/identities".into(), + app_config_path: root.join("config/apps/cli/config.toml"), + workspace_config_path: None, + app_data_root: data.join("apps/cli"), + app_logs_root: logs.join("apps/cli"), + shared_accounts_data_root: data.join("shared/accounts"), + shared_accounts_secrets_root: secrets.join("shared/accounts"), + default_identity_path: secrets.join("shared/identities/default.json"), + }, + migration: MigrationConfig { + report: RadrootsMigrationReport::empty(), + }, + logging: LoggingConfig { + filter: "info".into(), + directory: None, + stdout: false, + }, + account: AccountConfig { + selector: None, + store_path: data.join("shared/accounts/store.json"), + secrets_dir: secrets.join("shared/accounts"), + secret_backend: RadrootsSecretBackend::EncryptedFile, + secret_fallback: None, + }, + account_secret_contract: AccountSecretContractConfig { + default_backend: "host_vault".into(), + default_fallback: Some("encrypted_file".into()), + allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], + host_vault_policy: Some("desktop".into()), + uses_protected_store: true, + }, + identity: IdentityConfig { + path: secrets.join("shared/identities/default.json"), + }, + signer: SignerConfig { + backend: SignerBackend::Local, + }, + relay: RelayConfig { + urls: Vec::new(), + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::Defaults, + }, + local: LocalConfig { + root: data.join("apps/cli/replica"), + replica_db_path: data.join("apps/cli/replica/replica.sqlite"), + backups_dir: data.join("apps/cli/replica/backups"), + exports_dir: data.join("apps/cli/replica/exports"), + }, + myc: MycConfig { + executable: PathBuf::from("myc"), + status_timeout_ms: 2_000, + }, + hyf: HyfConfig { + enabled: false, + executable: PathBuf::from("hyfd"), + }, + rpc: RpcConfig { + url: "http://127.0.0.1:7070".into(), + bridge_bearer_token: None, + }, + capability_bindings: Vec::new(), + } + } + + fn data(entries: &[(&str, &str)]) -> OperationData { + entries + .iter() + .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) + .collect::<Map<String, Value>>() + } +}