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:
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(),