cli

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

commit 5fe78da93769cbf66df9b1eb91460cdf1c21fa2c
parent f08c10945ffc172b13d2ee079b09b5c1d93589c4
Author: triesap <tyson@radroots.org>
Date:   Wed, 15 Apr 2026 02:55:26 +0000

cli: expose localhost workflow truth in order

Diffstat:
MCargo.lock | 311++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCargo.toml | 3+++
Msrc/domain/runtime.rs | 23+++++++++++++++++++++++
Msrc/render/mod.rs | 42++++++++++++++++++++++++++++++++++++++++--
Msrc/runtime/order.rs | 500+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
5 files changed, 865 insertions(+), 14 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -160,6 +160,43 @@ dependencies = [ ] [[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + +[[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -529,6 +566,12 @@ dependencies = [ ] [[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] name = "dbus" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -597,6 +640,12 @@ dependencies = [ ] [[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -703,6 +752,20 @@ dependencies = [ ] [[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] name = "futures-channel" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -742,6 +805,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-sink", @@ -802,6 +866,18 @@ dependencies = [ ] [[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -941,7 +1017,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -1239,7 +1315,7 @@ dependencies = [ "bitflags", "libc", "plain", - "redox_syscall", + "redox_syscall 0.7.4", ] [[package]] @@ -1276,12 +1352,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1330,6 +1421,12 @@ dependencies = [ ] [[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + +[[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1365,6 +1462,59 @@ dependencies = [ ] [[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio", + "tracing", +] + +[[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1465,6 +1615,29 @@ dependencies = [ ] [[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] name = "password-hash" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1730,11 +1903,13 @@ dependencies = [ "radroots_events_codec", "radroots_identity", "radroots_log", + "radroots_nostr", "radroots_nostr_accounts", "radroots_nostr_signer", "radroots_protected_store", "radroots_replica_db", "radroots_replica_sync", + "radroots_runtime", "radroots_runtime_distribution", "radroots_runtime_manager", "radroots_runtime_paths", @@ -1743,6 +1918,7 @@ dependencies = [ "radroots_sql_core", "radroots_trade", "reqwest", + "rhi", "serde", "serde_json", "tar", @@ -1817,6 +1993,11 @@ name = "radroots_nostr" version = "0.1.0-alpha.2" dependencies = [ "nostr", + "nostr-sdk", + "radroots_events", + "radroots_events_codec", + "radroots_identity", + "reqwest", "serde", "serde_json", "thiserror 1.0.69", @@ -1924,6 +2105,7 @@ version = "0.1.0-alpha.2" dependencies = [ "anyhow", "chacha20poly1305", + "clap", "config", "getrandom 0.2.17", "radroots_log", @@ -2082,6 +2264,15 @@ dependencies = [ [[package]] name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" @@ -2143,7 +2334,29 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.6", +] + +[[package]] +name = "rhi" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "radroots_core", + "radroots_events", + "radroots_events_codec", + "radroots_identity", + "radroots_nostr", + "radroots_runtime", + "radroots_runtime_paths", + "radroots_trade", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "toml", + "tracing", ] [[package]] @@ -2304,6 +2517,12 @@ dependencies = [ ] [[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] name = "scrypt" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2442,6 +2661,17 @@ dependencies = [ ] [[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2728,6 +2958,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2757,6 +2988,34 @@ dependencies = [ ] [[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] name = "toml" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2849,6 +3108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] @@ -2865,6 +3125,17 @@ dependencies = [ ] [[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2932,6 +3203,25 @@ dependencies = [ ] [[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3022,6 +3312,12 @@ dependencies = [ ] [[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3216,6 +3512,15 @@ dependencies = [ [[package]] name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" diff --git a/Cargo.toml b/Cargo.toml @@ -27,6 +27,7 @@ radroots_events_codec = { path = "../lib/crates/events_codec", features = ["serd radroots_identity = { path = "../lib/crates/identity" } radroots_log = { path = "../lib/crates/log" } radroots_nostr_accounts = { path = "../lib/crates/nostr_accounts", features = ["os-keyring"] } +radroots_nostr = { path = "../lib/crates/nostr" } radroots_nostr_signer = { path = "../lib/crates/nostr_signer" } radroots_protected_store = { path = "../lib/crates/protected_store", features = ["std"] } radroots_replica_db = { path = "../lib/crates/replica_db" } @@ -34,10 +35,12 @@ radroots_replica_sync = { path = "../lib/crates/replica_sync" } radroots_runtime_distribution = { path = "../lib/crates/runtime_distribution" } radroots_runtime_manager = { path = "../lib/crates/runtime_manager" } radroots_runtime_paths = { path = "../lib/crates/runtime_paths" } +radroots_runtime = { path = "../lib/crates/runtime" } radroots_secret_vault = { path = "../lib/crates/secret_vault", features = ["std", "os-keyring"] } radroots_sdk = { path = "../lib/crates/sdk", default-features = false, features = ["std", "serde", "serde_json", "radrootsd-client"] } radroots_sql_core = { path = "../lib/crates/sql_core", features = ["native"] } radroots_trade = { path = "../lib/crates/trade" } +rhi = { path = "../rhi" } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -825,6 +825,8 @@ pub struct OrderGetView { #[serde(skip_serializing_if = "Option::is_none")] pub job: Option<OrderJobView>, #[serde(skip_serializing_if = "Option::is_none")] + pub workflow: Option<OrderWorkflowView>, + #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub issues: Vec<OrderIssueView>, @@ -920,6 +922,8 @@ pub struct OrderWatchView { pub interval_ms: u64, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub workflow: Option<OrderWorkflowView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub frames: Vec<OrderWatchFrameView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -950,6 +954,23 @@ pub struct OrderWatchFrameView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderWorkflowView { + pub state: String, + pub source: String, + pub order_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub validated_listing_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 last_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct OrderHistoryView { pub state: String, pub source: String, @@ -986,6 +1007,8 @@ pub struct OrderHistoryEntryView { pub updated_at_unix: u64, #[serde(skip_serializing_if = "Option::is_none")] pub job: Option<OrderJobView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub workflow: Option<OrderWorkflowView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub issues: Vec<OrderIssueView>, } diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -6,8 +6,9 @@ use crate::domain::runtime::{ ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView, - RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView, RuntimeLogsView, - RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView, SyncWatchView, + OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView, + RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView, + SyncWatchView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -1314,6 +1315,9 @@ fn render_order_get(stdout: &mut dyn Write, view: &OrderGetView) -> Result<(), R if let Some(job) = &view.job { render_order_job(stdout, job)?; } + if let Some(workflow) = &view.workflow { + render_order_workflow(stdout, workflow)?; + } render_order_issues(stdout, &view.issues)?; if let Some(reason) = &view.reason { writeln!(stdout, "reason: {reason}")?; @@ -1479,6 +1483,9 @@ fn render_order_watch(stdout: &mut dyn Write, view: &OrderWatchView) -> Result<( render_table(stdout, &table)?; writeln!(stdout)?; } + if let Some(workflow) = &view.workflow { + render_order_workflow(stdout, workflow)?; + } if let Some(reason) = &view.reason { writeln!(stdout, "reason: {reason}")?; } @@ -1626,6 +1633,37 @@ fn render_order_job(stdout: &mut dyn Write, job: &OrderJobView) -> Result<(), Ru Ok(()) } +fn render_order_workflow( + stdout: &mut dyn Write, + workflow: &OrderWorkflowView, +) -> Result<(), RuntimeError> { + let mut rows = vec![ + ("state", workflow.state.as_str()), + ("order id", workflow.order_id.as_str()), + ]; + if let Some(listing_addr) = &workflow.listing_addr { + rows.push(("listing addr", listing_addr.as_str())); + } + if let Some(validated_listing_event_id) = &workflow.validated_listing_event_id { + rows.push(( + "validated listing event", + validated_listing_event_id.as_str(), + )); + } + if let Some(root_event_id) = &workflow.root_event_id { + rows.push(("root event id", root_event_id.as_str())); + } + if let Some(last_event_id) = &workflow.last_event_id { + rows.push(("last event id", last_event_id.as_str())); + } + render_pairs(stdout, "workflow", rows.as_slice())?; + if let Some(reason) = &workflow.reason { + writeln!(stdout, "workflow reason: {reason}")?; + } + writeln!(stdout, "workflow source: {}", workflow.source)?; + Ok(()) +} + fn render_order_issues( stdout: &mut dyn Write, issues: &[crate::domain::runtime::OrderIssueView], diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use radroots_events::kinds::KIND_LISTING; use radroots_events::trade::{ @@ -10,24 +10,45 @@ use radroots_events::trade::{ }; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::trade::RadrootsTradeListingAddress; +use radroots_runtime::BackoffConfig; +use radroots_runtime_paths::{ + RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsRuntimeNamespace, +}; +use radroots_sdk::config::RadrootsSdkConfig; +use rhi::features::trade_listing::state::{TradeListingRuntime, TradeListingRuntimeConfig}; +use rhi::identity_storage::load_service_identity; +use rhi::rhi::{Rhi, start_subscriber}; use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; use crate::cli::{OrderNewArgs, OrderSubmitArgs, OrderWatchArgs, RecordKeyArgs}; use crate::domain::runtime::{ OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryEntryView, OrderHistoryView, OrderIssueView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderSummaryView, - OrderWatchFrameView, OrderWatchView, + OrderWatchFrameView, OrderWatchView, OrderWorkflowView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; -use crate::runtime::config::RuntimeConfig; +use crate::runtime::config::{ + CapabilityBindingTargetKind, RuntimeConfig, WORKFLOW_TRADE_CAPABILITY, +}; use crate::runtime::daemon::{self, DaemonRpcError}; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; const ORDER_DRAFT_KIND: &str = "order_draft_v1"; const ORDER_SOURCE: &str = "local order drafts · local first"; const ORDER_LIFECYCLE_SOURCE: &str = "local order drafts · durable job lifecycle"; +const ORDER_WORKFLOW_SOURCE: &str = "local order drafts · substrate-authoritative workflow state"; const ORDERS_DIR: &str = "orders/drafts"; +const WORKFLOW_PROVIDER_RUNTIME_ID: &str = "rhi"; +const WORKFLOW_TARGET: &str = "workflow-default"; +const WORKFLOW_STATE_DIR_NAME: &str = "trade-listing"; +const WORKFLOW_STATE_FILE_NAME: &str = "state.json"; +const WORKFLOW_IDENTITY_FILE_NAME: &str = "identity.secret.json"; +const WORKFLOW_FETCH_TIMEOUT: Duration = Duration::from_secs(30); +const WORKFLOW_POLL_INTERVAL: Duration = Duration::from_millis(250); +const WORKFLOW_REPLAY_WINDOW_SECS: u64 = 24 * 60 * 60; +const WORKFLOW_REPLAY_OVERLAP_SECS: u64 = 5 * 60; static ORDER_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -93,6 +114,36 @@ struct LoadedOrderDraft { document: OrderDraftDocument, } +#[derive(Debug, Clone)] +struct WorkflowContext { + relay_url: String, + identity_path: PathBuf, + state_path: PathBuf, +} + +#[derive(Debug, Clone)] +enum WorkflowResolutionError { + Unconfigured(String), + Unavailable(String), + Error(String), +} + +impl WorkflowResolutionError { + fn state(&self) -> &'static str { + match self { + Self::Unconfigured(_) => "unconfigured", + Self::Unavailable(_) => "unavailable", + Self::Error(_) => "error", + } + } + + fn reason(self) -> String { + match self { + Self::Unconfigured(reason) | Self::Unavailable(reason) | Self::Error(reason) => reason, + } + } +} + pub fn scaffold(config: &RuntimeConfig, args: &OrderNewArgs) -> Result<OrderNewView, RuntimeError> { validate_scaffold_args(args)?; @@ -177,6 +228,7 @@ pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<OrderGetView, items: Vec::new(), updated_at_unix: None, job: None, + workflow: None, reason: Some(format!("order draft `{lookup}` was not found")), issues: Vec::new(), actions: vec![ @@ -203,6 +255,7 @@ pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<OrderGetView, items: Vec::new(), updated_at_unix: None, job: None, + workflow: None, reason: Some(reason), issues: Vec::new(), actions: Vec::new(), @@ -537,6 +590,7 @@ pub fn watch( job_id: None, interval_ms: args.interval_ms, reason: Some(format!("order draft `{}` was not found", args.key)), + workflow: None, frames: Vec::new(), actions: vec!["radroots order ls".to_owned()], }); @@ -552,6 +606,7 @@ pub fn watch( job_id: None, interval_ms: args.interval_ms, reason: Some(reason), + workflow: None, frames: Vec::new(), actions: Vec::new(), }); @@ -566,6 +621,7 @@ pub fn watch( job_id: None, interval_ms: args.interval_ms, reason: Some("order draft does not have a recorded submission job yet".to_owned()), + workflow: None, frames: Vec::new(), actions: vec![format!( "radroots order submit {}", @@ -590,17 +646,53 @@ pub fn watch( summary: job.relay_outcome_summary.clone(), }); if job.terminal || frames.len() >= max_frames { + let workflow = if job.terminal + && job_state_allows_workflow_verification(job.state.as_str()) + { + match wait_for_order_workflow_truth(config, &loaded.document) { + Ok(workflow) => workflow, + Err(error) => { + let state = error.state().to_owned(); + let reason = error.reason(); + let workflow = workflow_error_view( + &loaded.document, + state.as_str(), + reason.clone(), + ); + return Ok(OrderWatchView { + state, + source: ORDER_WORKFLOW_SOURCE.to_owned(), + order_id: loaded.document.order.order_id.clone(), + job_id: Some(job_id.clone()), + interval_ms: args.interval_ms, + reason: Some(reason.clone()), + workflow: Some(workflow), + frames, + actions: vec!["radroots order history".to_owned()], + }); + } + } + } else { + None + }; return Ok(OrderWatchView { - state: if job.terminal { + state: if let Some(workflow) = workflow.as_ref() { + workflow.state.clone() + } else if job.terminal { job.state } else { "watching".to_owned() }, - source: ORDER_LIFECYCLE_SOURCE.to_owned(), + source: if workflow.is_some() { + ORDER_WORKFLOW_SOURCE.to_owned() + } else { + ORDER_LIFECYCLE_SOURCE.to_owned() + }, order_id: loaded.document.order.order_id.clone(), job_id: Some(job_id.clone()), interval_ms: args.interval_ms, reason: None, + workflow, frames, actions: vec!["radroots order history".to_owned()], }); @@ -614,6 +706,7 @@ pub fn watch( job_id: Some(job_id.clone()), interval_ms: args.interval_ms, reason: Some("recorded job id was not found in radrootsd".to_owned()), + workflow: None, frames, actions: vec!["radroots order history".to_owned()], }); @@ -796,6 +889,10 @@ fn view_from_loaded( issues, job, } = inspect_document(config, &loaded.document, enrich_job); + let workflow = resolve_order_workflow_snapshot(config, &loaded.document) + .ok() + .flatten(); + let state = preferred_order_state(state, workflow.as_ref()); let mut actions = actions_for_document(&loaded.document, loaded.file.as_path(), issues.as_slice()); @@ -828,6 +925,7 @@ fn view_from_loaded( .collect(), updated_at_unix: Some(loaded.updated_at_unix), job, + workflow, reason: None, issues, actions, @@ -864,6 +962,9 @@ fn history_entry_from_loaded( loaded: &LoadedOrderDraft, ) -> OrderHistoryEntryView { let job = submission_job_view(config, &loaded.document, true); + let workflow = resolve_order_workflow_snapshot(config, &loaded.document) + .ok() + .flatten(); let submitted_at_unix = loaded .document .submission @@ -871,16 +972,19 @@ fn history_entry_from_loaded( .and_then(|submission| submission.submitted_at_unix); OrderHistoryEntryView { id: loaded.document.order.order_id.clone(), - state: job - .as_ref() - .map(|job| job.state.clone()) - .unwrap_or_else(|| "recorded".to_owned()), + state: preferred_order_state( + job.as_ref() + .map(|job| job.state.clone()) + .unwrap_or_else(|| "recorded".to_owned()), + workflow.as_ref(), + ), listing_lookup: loaded.document.listing_lookup.clone(), listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), buyer_account_id: loaded.document.buyer_account_id.clone(), submitted_at_unix, updated_at_unix: loaded.updated_at_unix, job, + workflow, issues: Vec::new(), } } @@ -1287,6 +1391,7 @@ fn order_watch_error_view( job_id: Some(job_id), interval_ms: args.interval_ms, reason: Some(reason), + workflow: None, frames, actions: if actions.is_empty() { Vec::new() @@ -1296,6 +1401,383 @@ fn order_watch_error_view( } } +fn resolve_order_workflow_snapshot( + config: &RuntimeConfig, + document: &OrderDraftDocument, +) -> Result<Option<OrderWorkflowView>, WorkflowResolutionError> { + let Some(context) = resolve_workflow_context(config)? else { + return Ok(None); + }; + load_order_workflow_view( + context.state_path.as_path(), + document.order.order_id.as_str(), + ) + .map_err(WorkflowResolutionError::Error) +} + +fn wait_for_order_workflow_truth( + config: &RuntimeConfig, + document: &OrderDraftDocument, +) -> Result<Option<OrderWorkflowView>, WorkflowResolutionError> { + let Some(context) = resolve_workflow_context(config)? else { + return Ok(None); + }; + + if let Some(workflow) = load_order_workflow_view( + context.state_path.as_path(), + document.order.order_id.as_str(), + ) + .map_err(WorkflowResolutionError::Error)? + { + if workflow_state_is_terminal(workflow.state.as_str()) { + return Ok(Some(workflow)); + } + } + + let identity = + load_service_identity(Some(context.identity_path.as_path()), false).map_err(|error| { + WorkflowResolutionError::Unconfigured(format!( + "workflow verification requires repo-local rhi identity at {}: {error}", + context.identity_path.display() + )) + })?; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| { + WorkflowResolutionError::Error(format!( + "build localhost workflow verifier runtime: {error}" + )) + })?; + + runtime.block_on(async move { + let trade_listing_runtime = TradeListingRuntime::load(TradeListingRuntimeConfig { + state_path: context.state_path.clone(), + replay_window_secs: WORKFLOW_REPLAY_WINDOW_SECS, + replay_overlap_secs: WORKFLOW_REPLAY_OVERLAP_SECS, + }) + .await + .map_err(|error| { + WorkflowResolutionError::Unavailable(format!( + "load repo-local rhi workflow state {}: {error}", + context.state_path.display() + )) + })?; + + let rhi = Rhi::with_trade_listing_runtime( + identity.keys().clone(), + trade_listing_runtime.clone(), + ); + rhi.client + .add_relay(context.relay_url.as_str()) + .await + .map_err(|error| { + WorkflowResolutionError::Unavailable(format!( + "attach localhost relay `{}` to workflow verifier: {error}", + context.relay_url + )) + })?; + + let handle = start_subscriber( + rhi.client.clone(), + identity.keys().clone(), + trade_listing_runtime, + BackoffConfig::default(), + ) + .await; + + let mut last_observed = load_order_workflow_view( + context.state_path.as_path(), + document.order.order_id.as_str(), + ) + .map_err(WorkflowResolutionError::Error)?; + let deadline = Instant::now() + WORKFLOW_FETCH_TIMEOUT; + + loop { + if let Some(workflow) = load_order_workflow_view( + context.state_path.as_path(), + document.order.order_id.as_str(), + ) + .map_err(WorkflowResolutionError::Error)? + { + if workflow_state_is_terminal(workflow.state.as_str()) { + handle.stop(); + handle.stopped().await; + return Ok(Some(workflow)); + } + last_observed = Some(workflow); + } + + if Instant::now() >= deadline { + handle.stop(); + handle.stopped().await; + let detail = match last_observed { + Some(view) => format!( + "workflow state did not reach a terminal value within {:?}; last observed workflow state was `{}`", + WORKFLOW_FETCH_TIMEOUT, view.state + ), + None => format!( + "workflow state did not appear within {:?} at {}", + WORKFLOW_FETCH_TIMEOUT, + context.state_path.display() + ), + }; + return Err(WorkflowResolutionError::Unavailable(detail)); + } + + tokio::time::sleep(WORKFLOW_POLL_INTERVAL).await; + } + }) +} + +fn resolve_workflow_context( + config: &RuntimeConfig, +) -> Result<Option<WorkflowContext>, WorkflowResolutionError> { + let Some(binding) = config.capability_binding(WORKFLOW_TRADE_CAPABILITY) else { + return Ok(None); + }; + + if binding.provider_runtime_id != WORKFLOW_PROVIDER_RUNTIME_ID { + return Err(WorkflowResolutionError::Unconfigured(format!( + "workflow.trade binding must use provider `{WORKFLOW_PROVIDER_RUNTIME_ID}`, got `{}`", + binding.provider_runtime_id + ))); + } + if binding.target_kind != CapabilityBindingTargetKind::ManagedInstance { + return Err(WorkflowResolutionError::Unconfigured(format!( + "workflow.trade binding must use target_kind `managed_instance`, got `{}`", + binding.target_kind.as_str() + ))); + } + if binding.target != WORKFLOW_TARGET { + return Err(WorkflowResolutionError::Unconfigured(format!( + "workflow.trade binding must target `{WORKFLOW_TARGET}`, got `{}`", + binding.target + ))); + } + if config.paths.profile != "repo_local" { + return Err(WorkflowResolutionError::Unconfigured( + "workflow.trade progression requires RADROOTS_CLI_PATHS_PROFILE=repo_local".to_owned(), + )); + } + let repo_local_root = config.paths.repo_local_root.as_ref().ok_or_else(|| { + WorkflowResolutionError::Unconfigured( + "workflow.trade progression requires a repo-local cli root".to_owned(), + ) + })?; + let canonical_relay_url = + canonical_local_relay_url().map_err(WorkflowResolutionError::Error)?; + if !config + .relay + .urls + .iter() + .any(|configured| loopback_endpoint_matches(configured, canonical_relay_url.as_str())) + { + return Err(WorkflowResolutionError::Unconfigured(format!( + "workflow.trade progression requires canonical localhost relay `{canonical_relay_url}`" + ))); + } + + let base_paths = RadrootsPathResolver::current() + .resolve( + RadrootsPathProfile::RepoLocal, + &RadrootsPathOverrides::repo_local(repo_local_root), + ) + .map_err(|error| { + WorkflowResolutionError::Error(format!( + "resolve repo-local workflow verifier roots from {}: {error}", + repo_local_root.display() + )) + })?; + let worker_namespace = + RadrootsRuntimeNamespace::worker(WORKFLOW_PROVIDER_RUNTIME_ID).map_err(|error| { + WorkflowResolutionError::Error(format!( + "resolve worker namespace `{WORKFLOW_PROVIDER_RUNTIME_ID}`: {error}" + )) + })?; + let worker_paths = base_paths.namespaced(&worker_namespace); + + Ok(Some(WorkflowContext { + relay_url: canonical_relay_url, + identity_path: worker_paths.secrets.join(WORKFLOW_IDENTITY_FILE_NAME), + state_path: worker_paths + .data + .join(WORKFLOW_STATE_DIR_NAME) + .join(WORKFLOW_STATE_FILE_NAME), + })) +} + +fn load_order_workflow_view( + state_path: &Path, + order_id: &str, +) -> Result<Option<OrderWorkflowView>, String> { + if !state_path.exists() { + return Ok(None); + } + + let raw = fs::read_to_string(state_path) + .map_err(|error| format!("read workflow state {}: {error}", state_path.display()))?; + let snapshot: JsonValue = serde_json::from_str(raw.as_str()) + .map_err(|error| format!("parse workflow state {}: {error}", state_path.display()))?; + let state = snapshot + .get("state") + .and_then(JsonValue::as_object) + .ok_or_else(|| { + format!( + "workflow state {} did not include top-level `state` object", + state_path.display() + ) + })?; + let orders = state + .get("orders") + .and_then(JsonValue::as_object) + .ok_or_else(|| { + format!( + "workflow state {} did not include `state.orders`", + state_path.display() + ) + })?; + let Some(order) = orders.get(order_id) else { + return Ok(None); + }; + let order_object = order.as_object().ok_or_else(|| { + format!( + "workflow state {} stored `state.orders.{order_id}` as a non-object", + state_path.display() + ) + })?; + let workflow_state = order_object + .get("status") + .and_then(JsonValue::as_str) + .ok_or_else(|| { + format!( + "workflow state {} did not include `status` for order `{order_id}`", + state_path.display() + ) + })?; + let listing_addr = order_object + .get("listing_addr") + .and_then(JsonValue::as_str) + .map(str::to_owned); + let validated_listing_event_id = listing_addr + .as_ref() + .and_then(|listing_addr| { + state + .get("validated_listing_events") + .and_then(JsonValue::as_object) + .and_then(|events| events.get(listing_addr)) + .and_then(JsonValue::as_object) + .and_then(|entry| entry.get("event_id")) + .and_then(JsonValue::as_str) + }) + .map(str::to_owned); + + Ok(Some(OrderWorkflowView { + state: workflow_state.to_owned(), + source: ORDER_WORKFLOW_SOURCE.to_owned(), + order_id: order_id.to_owned(), + listing_addr, + validated_listing_event_id, + root_event_id: order_object + .get("root_event_id") + .and_then(JsonValue::as_str) + .map(str::to_owned), + last_event_id: order_object + .get("last_event_id") + .and_then(JsonValue::as_str) + .map(str::to_owned), + reason: None, + })) +} + +fn preferred_order_state(base_state: String, workflow: Option<&OrderWorkflowView>) -> String { + match workflow { + Some(workflow) if workflow_state_is_business_truth(workflow.state.as_str()) => { + workflow.state.clone() + } + _ => base_state, + } +} + +fn workflow_state_is_business_truth(state: &str) -> bool { + matches!( + state, + "draft" + | "validated" + | "requested" + | "questioned" + | "revised" + | "accepted" + | "declined" + | "cancelled" + | "fulfilled" + | "completed" + ) +} + +fn workflow_state_is_terminal(state: &str) -> bool { + matches!(state, "declined" | "cancelled" | "completed") +} + +fn job_state_allows_workflow_verification(state: &str) -> bool { + !matches!( + state, + "failed" | "error" | "missing" | "unavailable" | "unconfigured" + ) +} + +fn workflow_error_view( + document: &OrderDraftDocument, + state: &str, + reason: String, +) -> OrderWorkflowView { + OrderWorkflowView { + state: state.to_owned(), + source: ORDER_WORKFLOW_SOURCE.to_owned(), + order_id: document.order.order_id.clone(), + listing_addr: non_empty_string(document.order.listing_addr.clone()), + validated_listing_event_id: None, + root_event_id: None, + last_event_id: None, + reason: Some(reason), + } +} + +fn canonical_local_relay_url() -> Result<String, String> { + let config = RadrootsSdkConfig::local(); + config + .resolved_relay_urls() + .map_err(|error| format!("resolve canonical localhost relay url: {error}"))? + .into_iter() + .next() + .ok_or_else(|| "missing canonical localhost relay url".to_owned()) +} + +fn loopback_endpoint_matches(left: &str, right: &str) -> bool { + let Ok(left_url) = url::Url::parse(left) else { + return false; + }; + let Ok(right_url) = url::Url::parse(right) else { + return false; + }; + + if left_url.scheme() != right_url.scheme() + || left_url.port_or_known_default() != right_url.port_or_known_default() + { + return false; + } + + match (left_url.host_str(), right_url.host_str()) { + (Some(left_host), Some(right_host)) if left_host == right_host => true, + (Some(left_host), Some(right_host)) => matches!( + (left_host, right_host), + ("127.0.0.1", "localhost") | ("localhost", "127.0.0.1") + ), + _ => false, + } +} + fn trade_order_from_document(document: &OrderDraftDocument) -> RadrootsTradeOrder { RadrootsTradeOrder { order_id: document.order.order_id.clone(),