commit d260c3884bf78adbfb0abcb08011b2be849ce868
parent c6e1fa25426319031125f57a2b0e78f9df1b7512
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 06:29:52 +0000
land daemon-backed job and rpc inspection surfaces
Diffstat:
25 files changed, 2747 insertions(+), 87 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -84,7 +84,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -95,7 +95,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -143,6 +143,12 @@ dependencies = [
]
[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -245,6 +251,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -270,6 +282,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -454,7 +472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
- "rand_core",
+ "rand_core 0.6.4",
"typenum",
]
@@ -526,7 +544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -575,6 +593,55 @@ dependencies = [
]
[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -599,13 +666,27 @@ dependencies = [
[[package]]
name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 6.0.0",
"wasip2",
"wasip3",
]
@@ -678,6 +759,105 @@ dependencies = [
]
[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -845,6 +1025,22 @@ dependencies = [
]
[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "iri-string"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -862,6 +1058,8 @@ version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [
+ "cfg-if",
+ "futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -925,6 +1123,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -953,7 +1157,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -996,7 +1200,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1049,7 +1253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
- "rand_core",
+ "rand_core 0.6.4",
"subtle",
]
@@ -1212,6 +1416,61 @@ dependencies = [
]
[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.2",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1222,6 +1481,12 @@ dependencies = [
[[package]]
name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
@@ -1231,6 +1496,7 @@ name = "radroots-cli"
version = "0.1.0"
dependencies = [
"assert_cmd",
+ "chrono",
"clap",
"radroots-core",
"radroots-events",
@@ -1243,6 +1509,7 @@ dependencies = [
"radroots-replica-sync",
"radroots-sql-core",
"radroots-trade",
+ "reqwest",
"serde",
"serde_json",
"tempfile",
@@ -1441,8 +1708,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
- "rand_chacha",
- "rand_core",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
]
[[package]]
@@ -1452,7 +1729,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
- "rand_core",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
]
[[package]]
@@ -1465,6 +1752,15 @@ dependencies = [
]
[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1482,6 +1778,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
name = "ron"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1550,6 +1900,12 @@ dependencies = [
]
[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1559,7 +1915,42 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
]
[[package]]
@@ -1569,6 +1960,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1595,7 +1992,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
- "rand",
+ "rand 0.8.5",
"secp256k1-sys",
"serde",
]
@@ -1668,6 +2065,18 @@ dependencies = [
]
[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1704,12 +2113,28 @@ dependencies = [
]
[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "sqlite-wasm-rs"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1751,6 +2176,15 @@ dependencies = [
]
[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1771,7 +2205,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1909,12 +2343,14 @@ version = "1.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
dependencies = [
+ "bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
+ "socket2",
"tokio-macros",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1929,6 +2365,16 @@ dependencies = [
]
[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1970,6 +2416,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "iri-string",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2031,6 +2522,12 @@ dependencies = [
]
[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
name = "ts-rs"
version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2124,6 +2621,12 @@ dependencies = [
]
[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2187,6 +2690,15 @@ dependencies = [
]
[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2225,6 +2737,16 @@ dependencies = [
]
[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "wasm-bindgen-macro"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2301,12 +2823,31 @@ dependencies = [
]
[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2370,6 +2911,24 @@ dependencies = [
[[package]]
name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
@@ -2378,6 +2937,135 @@ dependencies = [
]
[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -17,6 +17,7 @@ path = "src/main.rs"
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
[dependencies]
+chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
clap = { version = "4.5", features = ["derive"] }
radroots-core = { path = "../lib/crates/core", features = ["std", "serde"] }
radroots-events = { path = "../lib/crates/events" }
@@ -29,6 +30,7 @@ radroots-replica-db = { path = "../lib/crates/replica-db" }
radroots-replica-sync = { path = "../lib/crates/replica-sync" }
radroots-sql-core = { path = "../lib/crates/sql-core", features = ["native"] }
radroots-trade = { path = "../lib/crates/trade" }
+reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
diff --git a/src/cli.rs b/src/cli.rs
@@ -141,6 +141,8 @@ impl Command {
command: RelayCommand::Ls,
}) | Self::Job(JobArgs {
command: JobCommand::Ls,
+ }) | Self::Job(JobArgs {
+ command: JobCommand::Watch(_),
}) | Self::Rpc(RpcArgs {
command: RpcCommand::Sessions,
}) | Self::Order(OrderArgs {
@@ -355,7 +357,16 @@ pub struct JobArgs {
pub enum JobCommand {
Ls,
Get(RecordKeyArgs),
- Watch(RecordKeyArgs),
+ Watch(JobWatchArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct JobWatchArgs {
+ pub key: String,
+ #[arg(long)]
+ pub frames: Option<usize>,
+ #[arg(long, default_value_t = 1_000)]
+ pub interval_ms: u64,
}
#[derive(Debug, Clone, Args)]
@@ -395,9 +406,9 @@ pub struct RecordKeyArgs {
#[cfg(test)]
mod tests {
use super::{
- AccountCommand, CliArgs, Command, ConfigCommand, JobCommand, ListingCommand, LocalCommand,
- LocalExportFormatArg, MycCommand, NetCommand, OrderCommand, RelayCommand, RpcCommand,
- SignerCommand, SyncCommand, SyncWatchArgs,
+ AccountCommand, CliArgs, Command, ConfigCommand, JobCommand, JobWatchArgs, ListingCommand,
+ LocalCommand, LocalExportFormatArg, MycCommand, NetCommand, OrderCommand, RelayCommand,
+ RpcCommand, SignerCommand, SyncCommand, SyncWatchArgs,
};
use crate::runtime::config::OutputFormat;
use clap::Parser;
@@ -652,8 +663,7 @@ mod tests {
_ => panic!("unexpected command variant"),
}
- let listing_publish =
- CliArgs::parse_from(["radroots", "listing", "publish", "draft.toml"]);
+ let listing_publish = CliArgs::parse_from(["radroots", "listing", "publish", "draft.toml"]);
match listing_publish.command {
Command::Listing(args) => match args.command {
ListingCommand::Publish(file) => {
@@ -682,6 +692,32 @@ mod tests {
_ => panic!("unexpected command variant"),
}
+ let job_watch = CliArgs::parse_from([
+ "radroots",
+ "job",
+ "watch",
+ "job_123",
+ "--frames",
+ "2",
+ "--interval-ms",
+ "5",
+ ]);
+ match job_watch.command {
+ Command::Job(args) => match args.command {
+ JobCommand::Watch(JobWatchArgs {
+ key,
+ frames,
+ interval_ms,
+ }) => {
+ assert_eq!(key, "job_123");
+ assert_eq!(frames, Some(2));
+ assert_eq!(interval_ms, 5);
+ }
+ _ => panic!("unexpected job watch subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
let rpc = CliArgs::parse_from(["radroots", "rpc", "status"]);
match rpc.command {
Command::Rpc(args) => match args.command {
diff --git a/src/commands/job.rs b/src/commands/job.rs
@@ -0,0 +1,16 @@
+use crate::cli::JobWatchArgs;
+use crate::domain::runtime::CommandOutput;
+use crate::runtime::RuntimeError;
+use crate::runtime::config::RuntimeConfig;
+
+pub fn list(config: &RuntimeConfig) -> CommandOutput {
+ crate::runtime::job::list(config)
+}
+
+pub fn get(config: &RuntimeConfig, job_id: &str) -> CommandOutput {
+ crate::runtime::job::get(config, job_id)
+}
+
+pub fn watch(config: &RuntimeConfig, args: &JobWatchArgs) -> Result<CommandOutput, RuntimeError> {
+ crate::runtime::job::watch(config, args)
+}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -1,11 +1,13 @@
pub mod doctor;
pub mod find;
pub mod identity;
+pub mod job;
pub mod listing;
pub mod local;
pub mod myc;
pub mod net;
pub mod relay;
+pub mod rpc;
pub mod runtime;
pub mod signer;
pub mod sync;
@@ -49,9 +51,9 @@ pub fn dispatch(
Command::Doctor => doctor::report(config, logging),
Command::Find(find_args) => find::search(config, find_args),
Command::Job(job) => match &job.command {
- JobCommand::Ls => unimplemented_command("job ls"),
- JobCommand::Get(_) => unimplemented_command("job get"),
- JobCommand::Watch(_) => unimplemented_command("job watch"),
+ JobCommand::Ls => Ok(job::list(config)),
+ JobCommand::Get(args) => Ok(job::get(config, args.key.as_str())),
+ JobCommand::Watch(args) => job::watch(config, args),
},
Command::Listing(listing) => match &listing.command {
ListingCommand::New(args) => listing::new(config, args),
@@ -83,8 +85,8 @@ pub fn dispatch(
RelayCommand::Ls => Ok(relay::list(config)),
},
Command::Rpc(rpc) => match &rpc.command {
- RpcCommand::Status => unimplemented_command("rpc status"),
- RpcCommand::Sessions => unimplemented_command("rpc sessions"),
+ RpcCommand::Status => Ok(rpc::status(config)),
+ RpcCommand::Sessions => Ok(rpc::sessions(config)),
},
Command::Sync(sync) => match &sync.command {
SyncCommand::Status => sync::status(config),
diff --git a/src/commands/rpc.rs b/src/commands/rpc.rs
@@ -0,0 +1,10 @@
+use crate::domain::runtime::CommandOutput;
+use crate::runtime::config::RuntimeConfig;
+
+pub fn status(config: &RuntimeConfig) -> CommandOutput {
+ crate::runtime::daemon::status(config)
+}
+
+pub fn sessions(config: &RuntimeConfig) -> CommandOutput {
+ crate::runtime::daemon::sessions(config)
+}
diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs
@@ -1,7 +1,7 @@
use crate::domain::runtime::{
AccountRuntimeView, ConfigFilesRuntimeView, ConfigShowView, LocalRuntimeView,
LoggingRuntimeView, MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView,
- SignerRuntimeView,
+ RpcRuntimeView, SignerRuntimeView,
};
use crate::runtime::config::RuntimeConfig;
use crate::runtime::logging::LoggingState;
@@ -62,5 +62,9 @@ pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> ConfigShowView {
myc: MycRuntimeView {
executable: config.myc.executable.display().to_string(),
},
+ rpc: RpcRuntimeView {
+ url: config.rpc.url.clone(),
+ bridge_auth_configured: config.rpc.bridge_bearer_token.is_some(),
+ },
}
}
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -75,6 +75,9 @@ pub enum CommandView {
ConfigShow(ConfigShowView),
Doctor(DoctorView),
Find(FindView),
+ JobGet(JobGetView),
+ JobList(JobListView),
+ JobWatch(JobWatchView),
ListingGet(ListingGetView),
ListingNew(ListingNewView),
ListingValidate(ListingValidateView),
@@ -84,6 +87,8 @@ pub enum CommandView {
LocalStatus(LocalStatusView),
MycStatus(MycStatusView),
NetStatus(NetStatusView),
+ RpcSessions(RpcSessionsView),
+ RpcStatus(RpcStatusView),
RelayList(RelayListView),
SignerStatus(SignerStatusView),
SyncPull(SyncActionView),
@@ -104,6 +109,7 @@ pub struct ConfigShowView {
pub relay: RelayRuntimeView,
pub local: LocalRuntimeView,
pub myc: MycRuntimeView,
+ pub rpc: RpcRuntimeView,
}
#[derive(Debug, Clone, Serialize)]
@@ -172,6 +178,12 @@ pub struct MycRuntimeView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct RpcRuntimeView {
+ pub url: String,
+ pub bridge_auth_configured: bool,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct DoctorView {
pub ok: bool,
pub state: String,
@@ -356,6 +368,96 @@ impl FindView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct JobListView {
+ pub state: String,
+ pub source: String,
+ pub rpc_url: String,
+ pub count: usize,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ pub jobs: Vec<JobSummaryView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct JobGetView {
+ pub state: String,
+ pub source: String,
+ pub rpc_url: String,
+ pub lookup: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub job: Option<JobDetailView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct JobWatchView {
+ pub state: String,
+ pub source: String,
+ pub rpc_url: String,
+ pub job_id: String,
+ pub interval_ms: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ pub frames: Vec<JobWatchFrameView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct JobSummaryView {
+ pub id: String,
+ pub command: String,
+ pub state: String,
+ pub terminal: bool,
+ pub signer: String,
+ pub requested_at_unix: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub completed_at_unix: Option<u64>,
+ pub recovered_after_restart: bool,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct JobDetailView {
+ pub id: String,
+ pub command: String,
+ pub state: String,
+ pub terminal: bool,
+ pub signer: String,
+ pub requested_at_unix: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub completed_at_unix: Option<u64>,
+ pub recovered_after_restart: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_addr: Option<String>,
+ pub delivery_policy: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub delivery_quorum: Option<usize>,
+ pub relay_count: usize,
+ pub acknowledged_relay_count: usize,
+ pub required_acknowledged_relay_count: usize,
+ pub attempt_count: usize,
+ pub relay_outcome_summary: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub attempt_summaries: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct JobWatchFrameView {
+ pub sequence: usize,
+ pub observed_at_unix: u64,
+ pub state: String,
+ pub terminal: bool,
+ pub summary: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct ListingNewView {
pub state: String,
pub source: String,
@@ -592,6 +694,76 @@ impl NetStatusView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct RpcStatusView {
+ pub state: String,
+ pub source: String,
+ pub url: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub auth_mode: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub signer_mode: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub default_signer_mode: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub supported_signer_modes: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub bridge_enabled: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub bridge_ready: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub relay_count: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub available_nip46_signer_sessions: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub job_status_retention: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub retained_jobs: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub accepted_jobs: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub published_jobs: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub failed_jobs: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub recovered_failed_jobs: Option<usize>,
+ pub session_surface_enabled: bool,
+ pub methods_count: usize,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct RpcSessionsView {
+ pub state: String,
+ pub source: String,
+ pub url: String,
+ pub count: usize,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ pub sessions: Vec<RpcSessionView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct RpcSessionView {
+ pub session_id: String,
+ pub role: String,
+ pub client_pubkey: String,
+ pub signer_pubkey: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user_pubkey: Option<String>,
+ pub relay_count: usize,
+ pub permissions_count: usize,
+ pub auth_required: bool,
+ pub authorized: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub expires_in_secs: Option<u64>,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct SyncStatusView {
pub state: String,
pub source: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -2,9 +2,10 @@ use std::io::{self, Write};
use crate::domain::runtime::{
AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView,
- FindView, ListingGetView, ListingNewView, ListingValidateView, LocalBackupView,
- LocalExportView, LocalInitView, LocalStatusView, NetStatusView, RelayListView, SyncActionView,
- SyncStatusView, SyncWatchView,
+ FindView, JobGetView, JobListView, JobWatchView, ListingGetView, ListingNewView,
+ ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView,
+ NetStatusView, RelayListView, RpcSessionsView, RpcStatusView, SyncActionView, SyncStatusView,
+ SyncWatchView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::{OutputConfig, OutputFormat};
@@ -77,6 +78,12 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::NetStatus(view) => {
render_net_status(stdout, view)?;
}
+ CommandView::RpcSessions(view) => {
+ render_rpc_sessions(stdout, view)?;
+ }
+ CommandView::RpcStatus(view) => {
+ render_rpc_status(stdout, view)?;
+ }
CommandView::ConfigShow(view) => {
render_config_show(stdout, view)?;
}
@@ -86,6 +93,15 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::Find(view) => {
render_find(stdout, view)?;
}
+ CommandView::JobGet(view) => {
+ render_job_get(stdout, view)?;
+ }
+ CommandView::JobList(view) => {
+ render_job_list(stdout, view)?;
+ }
+ CommandView::JobWatch(view) => {
+ render_job_watch(stdout, view)?;
+ }
CommandView::ListingGet(view) => {
render_listing_get(stdout, view)?;
}
@@ -189,6 +205,14 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::RpcSessions(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::RpcStatus(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::ConfigShow(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -201,6 +225,18 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::JobGet(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::JobList(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::JobWatch(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::ListingGet(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -285,6 +321,27 @@ fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<()
}
Ok(())
}
+ CommandView::JobList(view) => {
+ for job in &view.jobs {
+ serde_json::to_writer(&mut *stdout, job)?;
+ writeln!(stdout)?;
+ }
+ Ok(())
+ }
+ CommandView::JobWatch(view) => {
+ for frame in &view.frames {
+ serde_json::to_writer(&mut *stdout, frame)?;
+ writeln!(stdout)?;
+ }
+ Ok(())
+ }
+ CommandView::RpcSessions(view) => {
+ for session in &view.sessions {
+ serde_json::to_writer(&mut *stdout, session)?;
+ writeln!(stdout)?;
+ }
+ Ok(())
+ }
CommandView::SyncWatch(view) => {
for frame in &view.frames {
serde_json::to_writer(&mut *stdout, frame)?;
@@ -421,6 +478,17 @@ fn render_config_show(
"myc",
&[("executable", view.myc.executable.as_str())],
)?;
+ render_pairs(
+ stdout,
+ "rpc",
+ &[
+ ("url", view.rpc.url.as_str()),
+ (
+ "bridge auth configured",
+ yes_no(view.rpc.bridge_auth_configured),
+ ),
+ ],
+ )?;
writeln!(stdout, "source: {}", view.source)?;
Ok(())
}
@@ -514,10 +582,136 @@ fn render_find(stdout: &mut dyn Write, view: &FindView) -> Result<(), RuntimeErr
Ok(())
}
-fn render_listing_new(
- stdout: &mut dyn Write,
- view: &ListingNewView,
-) -> Result<(), RuntimeError> {
+fn render_job_list(stdout: &mut dyn Write, view: &JobListView) -> Result<(), RuntimeError> {
+ let context = match view.state.as_str() {
+ "ready" => format!(
+ "activity ยท {} job{}",
+ view.count,
+ if view.count == 1 { "" } else { "s" }
+ ),
+ "empty" => "activity ยท no retained jobs".to_owned(),
+ "unconfigured" => "activity ยท jobs unconfigured".to_owned(),
+ "unavailable" => "activity ยท jobs unavailable".to_owned(),
+ _ => "activity ยท jobs error".to_owned(),
+ };
+ write_context(stdout, context.as_str())?;
+ if view.jobs.is_empty() {
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "{reason}")?;
+ writeln!(stdout)?;
+ }
+ } else {
+ let table = Table {
+ headers: &["job", "type", "state", "signer", "updated"],
+ rows: view
+ .jobs
+ .iter()
+ .map(|job| {
+ let updated_at = job.completed_at_unix.unwrap_or(job.requested_at_unix);
+ vec![
+ job.id.clone(),
+ job.command.clone(),
+ job.state.clone(),
+ job.signer.clone(),
+ crate::runtime::job::format_timestamp(updated_at),
+ ]
+ })
+ .collect(),
+ };
+ render_table(stdout, &table)?;
+ writeln!(stdout)?;
+ }
+ writeln!(stdout, "rpc url: {}", view.rpc_url)?;
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_job_get(stdout: &mut dyn Write, view: &JobGetView) -> Result<(), RuntimeError> {
+ write_context(stdout, format!("activity ยท {}", view.lookup).as_str())?;
+ if let Some(job) = &view.job {
+ render_owned_pairs(
+ stdout,
+ "job",
+ &[
+ ("id", job.id.clone()),
+ ("type", job.command.clone()),
+ ("state", job.state.clone()),
+ ("signer", job.signer.clone()),
+ (
+ "requested",
+ crate::runtime::job::format_timestamp(job.requested_at_unix),
+ ),
+ (
+ "completed",
+ job.completed_at_unix
+ .map(crate::runtime::job::format_timestamp)
+ .unwrap_or_else(|| "pending".to_owned()),
+ ),
+ ("terminal", yes_no(job.terminal).to_owned()),
+ (
+ "recovered after restart",
+ yes_no(job.recovered_after_restart).to_owned(),
+ ),
+ ("delivery policy", job.delivery_policy.clone()),
+ ("relay outcome", job.relay_outcome_summary.clone()),
+ ],
+ )?;
+ if !job.attempt_summaries.is_empty() {
+ writeln!(stdout, "attempts")?;
+ for attempt in &job.attempt_summaries {
+ writeln!(stdout, " {attempt}")?;
+ }
+ writeln!(stdout)?;
+ }
+ } else if let Some(reason) = &view.reason {
+ writeln!(stdout, "{reason}")?;
+ writeln!(stdout)?;
+ }
+ writeln!(stdout, "rpc url: {}", view.rpc_url)?;
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_job_watch(stdout: &mut dyn Write, view: &JobWatchView) -> Result<(), RuntimeError> {
+ write_context(stdout, format!("activity ยท watch {}", view.job_id).as_str())?;
+ if view.frames.is_empty() {
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "{reason}")?;
+ writeln!(stdout)?;
+ } else {
+ writeln!(stdout, "no frames collected")?;
+ writeln!(stdout)?;
+ }
+ } else {
+ let table = Table {
+ headers: &["frame", "time", "state", "terminal", "summary"],
+ rows: view
+ .frames
+ .iter()
+ .map(|frame| {
+ vec![
+ frame.sequence.to_string(),
+ crate::runtime::job::format_clock(frame.observed_at_unix),
+ frame.state.clone(),
+ yes_no(frame.terminal).to_owned(),
+ frame.summary.clone(),
+ ]
+ })
+ .collect(),
+ };
+ render_table(stdout, &table)?;
+ writeln!(stdout)?;
+ }
+ writeln!(stdout, "interval ms: {}", view.interval_ms)?;
+ writeln!(stdout, "rpc url: {}", view.rpc_url)?;
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_listing_new(stdout: &mut dyn Write, view: &ListingNewView) -> Result<(), RuntimeError> {
write_context(stdout, "listing ยท draft created")?;
let mut rows = vec![
("file", view.file.as_str()),
@@ -590,10 +784,7 @@ fn render_listing_validate(
Ok(())
}
-fn render_listing_get(
- stdout: &mut dyn Write,
- view: &ListingGetView,
-) -> Result<(), RuntimeError> {
+fn render_listing_get(stdout: &mut dyn Write, view: &ListingGetView) -> Result<(), RuntimeError> {
let context = view
.listing_id
.clone()
@@ -735,6 +926,95 @@ fn render_net_status(stdout: &mut dyn Write, view: &NetStatusView) -> Result<(),
Ok(())
}
+fn render_rpc_status(stdout: &mut dyn Write, view: &RpcStatusView) -> Result<(), RuntimeError> {
+ write_context(stdout, format!("rpc ยท {}", view.state).as_str())?;
+ let mut rows = vec![("url", view.url.as_str()), ("status", view.state.as_str())];
+ if let Some(auth_mode) = &view.auth_mode {
+ rows.push(("auth mode", auth_mode.as_str()));
+ }
+ if let Some(signer_mode) = &view.signer_mode {
+ rows.push(("signer mode", signer_mode.as_str()));
+ }
+ if let Some(default_signer_mode) = &view.default_signer_mode {
+ rows.push(("default signer", default_signer_mode.as_str()));
+ }
+ render_pairs(stdout, "rpc", rows.as_slice())?;
+
+ let mut bridge_rows = Vec::<(&str, String)>::new();
+ if let Some(enabled) = view.bridge_enabled {
+ bridge_rows.push(("bridge enabled", yes_no(enabled).to_owned()));
+ }
+ if let Some(ready) = view.bridge_ready {
+ bridge_rows.push(("bridge ready", yes_no(ready).to_owned()));
+ }
+ if let Some(relay_count) = view.relay_count {
+ bridge_rows.push(("relay count", relay_count.to_string()));
+ }
+ if let Some(retained_jobs) = view.retained_jobs {
+ bridge_rows.push(("retained jobs", retained_jobs.to_string()));
+ }
+ if let Some(job_status_retention) = view.job_status_retention {
+ bridge_rows.push(("job retention", job_status_retention.to_string()));
+ }
+ if !bridge_rows.is_empty() {
+ render_owned_pairs(stdout, "bridge", bridge_rows.as_slice())?;
+ }
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "reason: {reason}")?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_rpc_sessions(stdout: &mut dyn Write, view: &RpcSessionsView) -> Result<(), RuntimeError> {
+ let context = match view.state.as_str() {
+ "ready" => format!(
+ "rpc ยท {} session{}",
+ view.count,
+ if view.count == 1 { "" } else { "s" }
+ ),
+ "empty" => "rpc ยท no public sessions".to_owned(),
+ "unconfigured" => "rpc ยท sessions unconfigured".to_owned(),
+ "unavailable" => "rpc ยท sessions unavailable".to_owned(),
+ _ => "rpc ยท sessions error".to_owned(),
+ };
+ write_context(stdout, context.as_str())?;
+ if view.sessions.is_empty() {
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "{reason}")?;
+ writeln!(stdout)?;
+ }
+ } else {
+ let table = Table {
+ headers: &["session", "role", "auth", "authorized", "relays", "expires"],
+ rows: view
+ .sessions
+ .iter()
+ .map(|session| {
+ vec![
+ session.session_id.clone(),
+ session.role.clone(),
+ yes_no(session.auth_required).to_owned(),
+ yes_no(session.authorized).to_owned(),
+ session.relay_count.to_string(),
+ session
+ .expires_in_secs
+ .map(|secs| format!("{secs}s"))
+ .unwrap_or_else(|| "n/a".to_owned()),
+ ]
+ })
+ .collect(),
+ };
+ render_table(stdout, &table)?;
+ writeln!(stdout)?;
+ }
+ writeln!(stdout, "rpc url: {}", view.url)?;
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
fn render_sync_status(stdout: &mut dyn Write, view: &SyncStatusView) -> Result<(), RuntimeError> {
write_context(
stdout,
@@ -1178,6 +1458,9 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::ConfigShow(_) => "config show",
CommandView::Doctor(_) => "doctor",
CommandView::Find(_) => "find",
+ CommandView::JobGet(_) => "job get",
+ CommandView::JobList(_) => "job ls",
+ CommandView::JobWatch(_) => "job watch",
CommandView::ListingGet(_) => "listing get",
CommandView::ListingNew(_) => "listing new",
CommandView::ListingValidate(_) => "listing validate",
@@ -1187,6 +1470,8 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::LocalStatus(_) => "local status",
CommandView::MycStatus(_) => "myc status",
CommandView::NetStatus(_) => "net status",
+ CommandView::RpcSessions(_) => "rpc sessions",
+ CommandView::RpcStatus(_) => "rpc status",
CommandView::RelayList(_) => "relay ls",
CommandView::SignerStatus(_) => "signer status",
CommandView::SyncPull(_) => "sync pull",
@@ -1206,7 +1491,7 @@ mod tests {
};
use crate::runtime::config::{
AccountConfig, IdentityConfig, LocalConfig, LoggingConfig, MycConfig, OutputConfig,
- OutputFormat, PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy,
+ OutputFormat, PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig,
RuntimeConfig, SignerBackend, SignerConfig, Verbosity,
};
use crate::runtime::logging::LoggingState;
@@ -1257,6 +1542,10 @@ mod tests {
myc: MycConfig {
executable: "myc".into(),
},
+ rpc: RpcConfig {
+ url: "http://127.0.0.1:7070".to_owned(),
+ bridge_bearer_token: None,
+ },
},
&LoggingState {
initialized: true,
@@ -1350,6 +1639,10 @@ mod tests {
myc: MycConfig {
executable: "myc".into(),
},
+ rpc: RpcConfig {
+ url: "http://127.0.0.1:7070".to_owned(),
+ bridge_bearer_token: None,
+ },
},
&LoggingState {
initialized: true,
diff --git a/src/runtime/config.rs b/src/runtime/config.rs
@@ -18,6 +18,7 @@ const DEFAULT_LOCAL_STATE_DIR: &str = "replica";
const DEFAULT_LOCAL_DB_FILE: &str = "replica.sqlite";
const DEFAULT_LOCAL_BACKUPS_DIR: &str = "backups";
const DEFAULT_LOCAL_EXPORTS_DIR: &str = "exports";
+const DEFAULT_RPC_URL: &str = "http://127.0.0.1:7070";
const ENV_FILE_PATH: &str = "RADROOTS_ENV_FILE";
const ENV_OUTPUT: &str = "RADROOTS_OUTPUT";
const ENV_CLI_LOG_FILTER: &str = "RADROOTS_CLI_LOGGING_FILTER";
@@ -31,6 +32,8 @@ const ENV_IDENTITY_PATH: &str = "RADROOTS_IDENTITY_PATH";
const ENV_SIGNER: &str = "RADROOTS_SIGNER";
const ENV_RELAYS: &str = "RADROOTS_RELAYS";
const ENV_MYC_EXECUTABLE: &str = "RADROOTS_MYC_EXECUTABLE";
+const ENV_RPC_URL: &str = "RADROOTS_RPC_URL";
+const ENV_RPC_BEARER_TOKEN: &str = "RADROOTS_RPC_BEARER_TOKEN";
const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[
ENV_OUTPUT,
ENV_CLI_LOG_FILTER,
@@ -44,6 +47,8 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[
ENV_SIGNER,
ENV_RELAYS,
ENV_MYC_EXECUTABLE,
+ ENV_RPC_URL,
+ ENV_RPC_BEARER_TOKEN,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -184,6 +189,12 @@ pub struct MycConfig {
}
#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RpcConfig {
+ pub url: String,
+ pub bridge_bearer_token: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeConfig {
pub output: OutputConfig,
pub paths: PathsConfig,
@@ -194,6 +205,7 @@ pub struct RuntimeConfig {
pub relay: RelayConfig,
pub local: LocalConfig,
pub myc: MycConfig,
+ pub rpc: RpcConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -209,6 +221,7 @@ struct EnvFileValues(BTreeMap<String, String>);
#[derive(Debug, Default, Deserialize)]
struct CliConfigFile {
relay: Option<RelayFileConfig>,
+ rpc: Option<RpcFileConfig>,
}
#[derive(Debug, Default, Deserialize)]
@@ -217,6 +230,11 @@ struct RelayFileConfig {
publish_policy: Option<String>,
}
+#[derive(Debug, Default, Deserialize)]
+struct RpcFileConfig {
+ url: Option<String>,
+}
+
pub trait Environment {
fn var(&self, key: &str) -> Option<String>;
fn current_dir(&self) -> Result<PathBuf, RuntimeError>;
@@ -338,6 +356,12 @@ impl RuntimeConfig {
.or_else(|| env_value(env, env_file, &[ENV_MYC_EXECUTABLE]).map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("myc")),
},
+ rpc: resolve_rpc_config(
+ env,
+ env_file,
+ user_config.as_ref(),
+ workspace_config.as_ref(),
+ )?,
})
}
}
@@ -383,6 +407,31 @@ fn load_cli_config_file(path: &Path) -> Result<Option<CliConfigFile>, RuntimeErr
})
}
+fn resolve_rpc_config(
+ env: &dyn Environment,
+ env_file: &EnvFileValues,
+ user_config: Option<&CliConfigFile>,
+ workspace_config: Option<&CliConfigFile>,
+) -> Result<RpcConfig, RuntimeError> {
+ let url = env_value(env, env_file, &[ENV_RPC_URL])
+ .or_else(|| {
+ user_config
+ .and_then(|config| config.rpc.as_ref())
+ .and_then(|rpc| rpc.url.clone())
+ })
+ .or_else(|| {
+ workspace_config
+ .and_then(|config| config.rpc.as_ref())
+ .and_then(|rpc| rpc.url.clone())
+ })
+ .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned());
+
+ Ok(RpcConfig {
+ url: validate_rpc_url(url.as_str())?,
+ bridge_bearer_token: env_value(env, env_file, &[ENV_RPC_BEARER_TOKEN]),
+ })
+}
+
fn resolve_relay_config(
args: &CliArgs,
env: &dyn Environment,
@@ -466,6 +515,21 @@ fn parse_relay_publish_policy(value: &str) -> Result<RelayPublishPolicy, Runtime
}
}
+fn validate_rpc_url(value: &str) -> Result<String, RuntimeError> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err(RuntimeError::Config("rpc url must not be empty".to_owned()));
+ }
+ let parsed = Url::parse(trimmed)
+ .map_err(|err| RuntimeError::Config(format!("rpc url `{trimmed}` is invalid: {err}")))?;
+ if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
+ return Err(RuntimeError::Config(format!(
+ "rpc url must use http or https, got `{trimmed}`"
+ )));
+ }
+ Ok(trimmed.to_owned())
+}
+
fn parse_relay_env_value(value: &str, key: &str) -> Result<Vec<String>, RuntimeError> {
let entries = value
.split(',')
diff --git a/src/runtime/daemon.rs b/src/runtime/daemon.rs
@@ -0,0 +1,498 @@
+use std::time::Duration;
+
+use reqwest::blocking::Client;
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::domain::runtime::{
+ CommandOutput, CommandView, JobDetailView, JobSummaryView, RpcSessionView, RpcSessionsView,
+ RpcStatusView,
+};
+use crate::runtime::config::RuntimeConfig;
+
+const RPC_SOURCE: &str = "daemon rpc ยท durable write plane";
+const BRIDGE_SOURCE: &str = "daemon bridge ยท durable write plane";
+const RPC_TIMEOUT_SECS: u64 = 2;
+
+#[derive(Debug)]
+pub enum DaemonRpcError {
+ Unconfigured(String),
+ Unauthorized(String),
+ MethodUnavailable(String),
+ UnknownJob(String),
+ External(String),
+ InvalidResponse(String),
+ Remote(String),
+}
+
+#[derive(Debug, Clone, Copy)]
+enum RpcAuthMode {
+ None,
+ BridgeBearer,
+}
+
+#[derive(Debug, Serialize)]
+struct JsonRpcRequest<'a> {
+ jsonrpc: &'static str,
+ id: u64,
+ method: &'a str,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ params: Option<Value>,
+}
+
+#[derive(Debug, Deserialize)]
+struct JsonRpcResponse<T> {
+ result: Option<T>,
+ error: Option<JsonRpcResponseError>,
+}
+
+#[derive(Debug, Deserialize)]
+struct JsonRpcResponseError {
+ code: i64,
+ message: String,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct BridgeStatusRemote {
+ enabled: bool,
+ ready: bool,
+ auth_mode: String,
+ signer_mode: String,
+ default_signer_mode: String,
+ #[serde(default)]
+ supported_signer_modes: Vec<String>,
+ available_nip46_signer_sessions: usize,
+ relay_count: usize,
+ job_status_retention: usize,
+ retained_jobs: usize,
+ accepted_jobs: usize,
+ published_jobs: usize,
+ failed_jobs: usize,
+ recovered_failed_jobs: usize,
+ #[serde(default)]
+ methods: Vec<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct BridgeJobRemote {
+ job_id: String,
+ command: String,
+ status: String,
+ terminal: bool,
+ recovered_after_restart: bool,
+ requested_at_unix: u64,
+ completed_at_unix: Option<u64>,
+ signer_mode: String,
+ event_id: Option<String>,
+ event_addr: Option<String>,
+ delivery_policy: String,
+ delivery_quorum: Option<usize>,
+ relay_count: usize,
+ acknowledged_relay_count: usize,
+ required_acknowledged_relay_count: usize,
+ attempt_count: usize,
+ relay_outcome_summary: String,
+ #[serde(default)]
+ attempt_summaries: Vec<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct Nip46SessionRemote {
+ session_id: String,
+ role: String,
+ client_pubkey: String,
+ signer_pubkey: String,
+ user_pubkey: Option<String>,
+ #[serde(default)]
+ relays: Vec<String>,
+ #[serde(default)]
+ permissions: Vec<String>,
+ auth_required: bool,
+ authorized: bool,
+ expires_in_secs: Option<u64>,
+}
+
+pub fn status(config: &RuntimeConfig) -> CommandOutput {
+ match bridge_status(config) {
+ Ok(status) => CommandOutput::success(CommandView::RpcStatus(RpcStatusView {
+ state: if status.ready {
+ "ready".to_owned()
+ } else {
+ "degraded".to_owned()
+ },
+ source: RPC_SOURCE.to_owned(),
+ url: config.rpc.url.clone(),
+ reason: if status.ready {
+ None
+ } else {
+ Some("bridge is reachable but not ready for durable publish traffic".to_owned())
+ },
+ auth_mode: Some(status.auth_mode),
+ signer_mode: Some(status.signer_mode),
+ default_signer_mode: Some(status.default_signer_mode),
+ supported_signer_modes: status.supported_signer_modes,
+ bridge_enabled: Some(status.enabled),
+ bridge_ready: Some(status.ready),
+ relay_count: Some(status.relay_count),
+ available_nip46_signer_sessions: Some(status.available_nip46_signer_sessions),
+ job_status_retention: Some(status.job_status_retention),
+ retained_jobs: Some(status.retained_jobs),
+ accepted_jobs: Some(status.accepted_jobs),
+ published_jobs: Some(status.published_jobs),
+ failed_jobs: Some(status.failed_jobs),
+ recovered_failed_jobs: Some(status.recovered_failed_jobs),
+ session_surface_enabled: status
+ .methods
+ .iter()
+ .any(|method| method == "nip46.session.list"),
+ methods_count: status.methods.len(),
+ actions: if status.ready {
+ Vec::new()
+ } else {
+ vec!["radroots relay ls".to_owned()]
+ },
+ })),
+ Err(DaemonRpcError::Unconfigured(reason))
+ | Err(DaemonRpcError::Unauthorized(reason))
+ | Err(DaemonRpcError::MethodUnavailable(reason)) => {
+ CommandOutput::unconfigured(CommandView::RpcStatus(RpcStatusView {
+ state: "unconfigured".to_owned(),
+ source: RPC_SOURCE.to_owned(),
+ url: config.rpc.url.clone(),
+ reason: Some(reason),
+ auth_mode: None,
+ signer_mode: None,
+ default_signer_mode: None,
+ supported_signer_modes: Vec::new(),
+ bridge_enabled: None,
+ bridge_ready: None,
+ relay_count: None,
+ available_nip46_signer_sessions: None,
+ job_status_retention: None,
+ retained_jobs: None,
+ accepted_jobs: None,
+ published_jobs: None,
+ failed_jobs: None,
+ recovered_failed_jobs: None,
+ session_surface_enabled: false,
+ methods_count: 0,
+ actions: vec![
+ "set RADROOTS_RPC_BEARER_TOKEN in .env or your shell".to_owned(),
+ "start radrootsd with bridge ingress enabled".to_owned(),
+ ],
+ }))
+ }
+ Err(DaemonRpcError::External(reason)) => {
+ CommandOutput::external_unavailable(CommandView::RpcStatus(RpcStatusView {
+ state: "unavailable".to_owned(),
+ source: RPC_SOURCE.to_owned(),
+ url: config.rpc.url.clone(),
+ reason: Some(reason),
+ auth_mode: None,
+ signer_mode: None,
+ default_signer_mode: None,
+ supported_signer_modes: Vec::new(),
+ bridge_enabled: None,
+ bridge_ready: None,
+ relay_count: None,
+ available_nip46_signer_sessions: None,
+ job_status_retention: None,
+ retained_jobs: None,
+ accepted_jobs: None,
+ published_jobs: None,
+ failed_jobs: None,
+ recovered_failed_jobs: None,
+ session_surface_enabled: false,
+ methods_count: 0,
+ actions: vec!["start radrootsd and verify the rpc url".to_owned()],
+ }))
+ }
+ Err(DaemonRpcError::InvalidResponse(reason)) | Err(DaemonRpcError::Remote(reason)) => {
+ CommandOutput::internal_error(CommandView::RpcStatus(RpcStatusView {
+ state: "error".to_owned(),
+ source: RPC_SOURCE.to_owned(),
+ url: config.rpc.url.clone(),
+ reason: Some(reason),
+ auth_mode: None,
+ signer_mode: None,
+ default_signer_mode: None,
+ supported_signer_modes: Vec::new(),
+ bridge_enabled: None,
+ bridge_ready: None,
+ relay_count: None,
+ available_nip46_signer_sessions: None,
+ job_status_retention: None,
+ retained_jobs: None,
+ accepted_jobs: None,
+ published_jobs: None,
+ failed_jobs: None,
+ recovered_failed_jobs: None,
+ session_surface_enabled: false,
+ methods_count: 0,
+ actions: vec!["inspect the daemon rpc response contract".to_owned()],
+ }))
+ }
+ Err(DaemonRpcError::UnknownJob(reason)) => {
+ CommandOutput::internal_error(CommandView::RpcStatus(RpcStatusView {
+ state: "error".to_owned(),
+ source: RPC_SOURCE.to_owned(),
+ url: config.rpc.url.clone(),
+ reason: Some(reason),
+ auth_mode: None,
+ signer_mode: None,
+ default_signer_mode: None,
+ supported_signer_modes: Vec::new(),
+ bridge_enabled: None,
+ bridge_ready: None,
+ relay_count: None,
+ available_nip46_signer_sessions: None,
+ job_status_retention: None,
+ retained_jobs: None,
+ accepted_jobs: None,
+ published_jobs: None,
+ failed_jobs: None,
+ recovered_failed_jobs: None,
+ session_surface_enabled: false,
+ methods_count: 0,
+ actions: Vec::new(),
+ }))
+ }
+ }
+}
+
+pub fn sessions(config: &RuntimeConfig) -> CommandOutput {
+ match nip46_sessions(config) {
+ Ok(sessions) => {
+ let entries = sessions
+ .into_iter()
+ .map(map_session_view)
+ .collect::<Vec<_>>();
+ let state = if entries.is_empty() { "empty" } else { "ready" };
+ CommandOutput::success(CommandView::RpcSessions(RpcSessionsView {
+ state: state.to_owned(),
+ source: RPC_SOURCE.to_owned(),
+ url: config.rpc.url.clone(),
+ count: entries.len(),
+ reason: None,
+ sessions: entries,
+ actions: Vec::new(),
+ }))
+ }
+ Err(DaemonRpcError::MethodUnavailable(reason)) => {
+ CommandOutput::unconfigured(CommandView::RpcSessions(RpcSessionsView {
+ state: "unconfigured".to_owned(),
+ source: RPC_SOURCE.to_owned(),
+ url: config.rpc.url.clone(),
+ count: 0,
+ reason: Some(reason),
+ sessions: Vec::new(),
+ actions: vec!["enable nip46.public_jsonrpc_enabled in radrootsd".to_owned()],
+ }))
+ }
+ Err(DaemonRpcError::External(reason)) => {
+ CommandOutput::external_unavailable(CommandView::RpcSessions(RpcSessionsView {
+ state: "unavailable".to_owned(),
+ source: RPC_SOURCE.to_owned(),
+ url: config.rpc.url.clone(),
+ count: 0,
+ reason: Some(reason),
+ sessions: Vec::new(),
+ actions: vec!["start radrootsd and verify the rpc url".to_owned()],
+ }))
+ }
+ Err(DaemonRpcError::Unconfigured(reason))
+ | Err(DaemonRpcError::Unauthorized(reason))
+ | Err(DaemonRpcError::InvalidResponse(reason))
+ | Err(DaemonRpcError::Remote(reason))
+ | Err(DaemonRpcError::UnknownJob(reason)) => {
+ CommandOutput::internal_error(CommandView::RpcSessions(RpcSessionsView {
+ state: "error".to_owned(),
+ source: RPC_SOURCE.to_owned(),
+ url: config.rpc.url.clone(),
+ count: 0,
+ reason: Some(reason),
+ sessions: Vec::new(),
+ actions: Vec::new(),
+ }))
+ }
+ }
+}
+
+pub fn bridge_job_list(config: &RuntimeConfig) -> Result<Vec<JobSummaryView>, DaemonRpcError> {
+ bridge_jobs(config).map(|jobs| jobs.into_iter().map(map_job_summary_view).collect())
+}
+
+pub fn bridge_job(
+ config: &RuntimeConfig,
+ job_id: &str,
+) -> Result<Option<JobDetailView>, DaemonRpcError> {
+ match bridge_job_status(config, job_id) {
+ Ok(job) => Ok(Some(map_job_detail_view(job))),
+ Err(DaemonRpcError::UnknownJob(_)) => Ok(None),
+ Err(error) => Err(error),
+ }
+}
+
+fn bridge_status(config: &RuntimeConfig) -> Result<BridgeStatusRemote, DaemonRpcError> {
+ call(config, "bridge.status", None, RpcAuthMode::BridgeBearer)
+}
+
+fn bridge_jobs(config: &RuntimeConfig) -> Result<Vec<BridgeJobRemote>, DaemonRpcError> {
+ call(config, "bridge.job.list", None, RpcAuthMode::BridgeBearer)
+}
+
+fn bridge_job_status(
+ config: &RuntimeConfig,
+ job_id: &str,
+) -> Result<BridgeJobRemote, DaemonRpcError> {
+ call(
+ config,
+ "bridge.job.status",
+ Some(serde_json::json!({ "job_id": job_id })),
+ RpcAuthMode::BridgeBearer,
+ )
+}
+
+fn nip46_sessions(config: &RuntimeConfig) -> Result<Vec<Nip46SessionRemote>, DaemonRpcError> {
+ call(config, "nip46.session.list", None, RpcAuthMode::None)
+}
+
+fn call<T: DeserializeOwned>(
+ config: &RuntimeConfig,
+ method: &str,
+ params: Option<Value>,
+ auth_mode: RpcAuthMode,
+) -> Result<T, DaemonRpcError> {
+ let client = Client::builder()
+ .timeout(Duration::from_secs(RPC_TIMEOUT_SECS))
+ .build()
+ .map_err(|error| DaemonRpcError::InvalidResponse(format!("build rpc client: {error}")))?;
+
+ let mut request = client.post(config.rpc.url.as_str()).json(&JsonRpcRequest {
+ jsonrpc: "2.0",
+ id: 1,
+ method,
+ params,
+ });
+
+ if matches!(auth_mode, RpcAuthMode::BridgeBearer) {
+ let Some(token) = config.rpc.bridge_bearer_token.as_deref() else {
+ return Err(DaemonRpcError::Unconfigured(
+ "bridge bearer token is not configured".to_owned(),
+ ));
+ };
+ request = request.bearer_auth(token);
+ }
+
+ let response = request.send().map_err(|error| {
+ DaemonRpcError::External(format!(
+ "failed to reach daemon rpc at {}: {error}",
+ config.rpc.url
+ ))
+ })?;
+ let status = response.status();
+ let body = response.text().map_err(|error| {
+ DaemonRpcError::InvalidResponse(format!("read daemon rpc response: {error}"))
+ })?;
+ if !status.is_success() {
+ return Err(DaemonRpcError::External(format!(
+ "daemon rpc returned http {}",
+ status.as_u16()
+ )));
+ }
+
+ let envelope: JsonRpcResponse<T> = serde_json::from_str(body.as_str()).map_err(|error| {
+ DaemonRpcError::InvalidResponse(format!("parse daemon rpc response: {error}"))
+ })?;
+ if let Some(result) = envelope.result {
+ return Ok(result);
+ }
+ let Some(error) = envelope.error else {
+ return Err(DaemonRpcError::InvalidResponse(
+ "daemon rpc response did not include a result".to_owned(),
+ ));
+ };
+ Err(map_rpc_error(method, error))
+}
+
+fn map_rpc_error(method: &str, error: JsonRpcResponseError) -> DaemonRpcError {
+ match error.code {
+ -32601 => DaemonRpcError::MethodUnavailable(error.message),
+ -32001 => DaemonRpcError::Unauthorized(error.message),
+ -32000
+ if method == "bridge.job.status"
+ && error.message.starts_with("unknown bridge job:") =>
+ {
+ DaemonRpcError::UnknownJob(error.message)
+ }
+ -32000 => DaemonRpcError::Remote(error.message),
+ _ => DaemonRpcError::InvalidResponse(format!(
+ "daemon rpc returned unexpected error {}: {}",
+ error.code, error.message
+ )),
+ }
+}
+
+fn map_job_command(command: String) -> String {
+ match command.as_str() {
+ "bridge.listing.publish" => "listing.publish".to_owned(),
+ "bridge.order.request" => "order.submit".to_owned(),
+ other => other.to_owned(),
+ }
+}
+
+fn map_job_summary_view(job: BridgeJobRemote) -> JobSummaryView {
+ JobSummaryView {
+ id: job.job_id,
+ command: map_job_command(job.command),
+ state: job.status,
+ terminal: job.terminal,
+ signer: job.signer_mode,
+ requested_at_unix: job.requested_at_unix,
+ completed_at_unix: job.completed_at_unix,
+ recovered_after_restart: job.recovered_after_restart,
+ }
+}
+
+fn map_job_detail_view(job: BridgeJobRemote) -> JobDetailView {
+ JobDetailView {
+ id: job.job_id,
+ command: map_job_command(job.command),
+ state: job.status,
+ terminal: job.terminal,
+ signer: job.signer_mode,
+ requested_at_unix: job.requested_at_unix,
+ completed_at_unix: job.completed_at_unix,
+ recovered_after_restart: job.recovered_after_restart,
+ event_id: job.event_id,
+ event_addr: job.event_addr,
+ delivery_policy: job.delivery_policy,
+ delivery_quorum: job.delivery_quorum,
+ relay_count: job.relay_count,
+ acknowledged_relay_count: job.acknowledged_relay_count,
+ required_acknowledged_relay_count: job.required_acknowledged_relay_count,
+ attempt_count: job.attempt_count,
+ relay_outcome_summary: job.relay_outcome_summary,
+ attempt_summaries: job.attempt_summaries,
+ }
+}
+
+fn map_session_view(session: Nip46SessionRemote) -> RpcSessionView {
+ RpcSessionView {
+ session_id: session.session_id,
+ role: session.role,
+ client_pubkey: session.client_pubkey,
+ signer_pubkey: session.signer_pubkey,
+ user_pubkey: session.user_pubkey,
+ relay_count: session.relays.len(),
+ permissions_count: session.permissions.len(),
+ auth_required: session.auth_required,
+ authorized: session.authorized,
+ expires_in_secs: session.expires_in_secs,
+ }
+}
+
+pub fn bridge_source() -> &'static str {
+ BRIDGE_SOURCE
+}
diff --git a/src/runtime/job.rs b/src/runtime/job.rs
@@ -0,0 +1,276 @@
+use std::thread;
+use std::time::Duration;
+
+use chrono::{DateTime, Utc};
+
+use crate::cli::JobWatchArgs;
+use crate::domain::runtime::{
+ CommandOutput, CommandView, JobGetView, JobListView, JobWatchFrameView, JobWatchView,
+};
+use crate::runtime::RuntimeError;
+use crate::runtime::config::RuntimeConfig;
+use crate::runtime::daemon::{self, DaemonRpcError};
+
+pub fn list(config: &RuntimeConfig) -> CommandOutput {
+ match daemon::bridge_job_list(config) {
+ Ok(jobs) => CommandOutput::success(CommandView::JobList(JobListView {
+ state: if jobs.is_empty() {
+ "empty".to_owned()
+ } else {
+ "ready".to_owned()
+ },
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ count: jobs.len(),
+ reason: None,
+ jobs,
+ actions: Vec::new(),
+ })),
+ Err(error) => error_job_list_view(config, error),
+ }
+}
+
+pub fn get(config: &RuntimeConfig, job_id: &str) -> CommandOutput {
+ match daemon::bridge_job(config, job_id) {
+ Ok(Some(job)) => CommandOutput::success(CommandView::JobGet(JobGetView {
+ state: "ready".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ lookup: job_id.to_owned(),
+ reason: None,
+ job: Some(job),
+ actions: Vec::new(),
+ })),
+ Ok(None) => CommandOutput::success(CommandView::JobGet(JobGetView {
+ state: "missing".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ lookup: job_id.to_owned(),
+ reason: Some(format!("job `{job_id}` was not found in radrootsd")),
+ job: None,
+ actions: vec!["radroots job ls".to_owned()],
+ })),
+ Err(error) => error_job_get_view(config, job_id, error),
+ }
+}
+
+pub fn watch(config: &RuntimeConfig, args: &JobWatchArgs) -> Result<CommandOutput, RuntimeError> {
+ if args.frames == Some(0) {
+ return Err(RuntimeError::Config(
+ "--frames must be greater than zero when provided".to_owned(),
+ ));
+ }
+
+ let mut frames = Vec::new();
+ let max_frames = args.frames.unwrap_or(usize::MAX);
+ loop {
+ match daemon::bridge_job(config, args.key.as_str()) {
+ Ok(Some(job)) => {
+ frames.push(JobWatchFrameView {
+ sequence: frames.len() + 1,
+ observed_at_unix: job.completed_at_unix.unwrap_or(job.requested_at_unix),
+ state: job.state.clone(),
+ terminal: job.terminal,
+ summary: job.relay_outcome_summary.clone(),
+ });
+ if job.terminal || frames.len() >= max_frames {
+ let state = if job.terminal {
+ job.state
+ } else {
+ "watching".to_owned()
+ };
+ return Ok(CommandOutput::success(CommandView::JobWatch(
+ JobWatchView {
+ state,
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ job_id: args.key.clone(),
+ interval_ms: args.interval_ms,
+ reason: None,
+ frames,
+ actions: Vec::new(),
+ },
+ )));
+ }
+ }
+ Ok(None) => {
+ return Ok(CommandOutput::success(CommandView::JobWatch(
+ JobWatchView {
+ state: "missing".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ job_id: args.key.clone(),
+ interval_ms: args.interval_ms,
+ reason: Some(format!("job `{}` was not found in radrootsd", args.key)),
+ frames,
+ actions: vec!["radroots job ls".to_owned()],
+ },
+ )));
+ }
+ Err(error) => {
+ return Ok(error_job_watch_view(config, args, frames, error));
+ }
+ }
+
+ thread::sleep(Duration::from_millis(args.interval_ms));
+ }
+}
+
+pub fn format_timestamp(unix: u64) -> String {
+ DateTime::<Utc>::from_timestamp(unix as i64, 0)
+ .map(|value| value.format("%Y-%m-%d %H:%M:%S UTC").to_string())
+ .unwrap_or_else(|| unix.to_string())
+}
+
+pub fn format_clock(unix: u64) -> String {
+ DateTime::<Utc>::from_timestamp(unix as i64, 0)
+ .map(|value| value.format("%H:%M:%S").to_string())
+ .unwrap_or_else(|| unix.to_string())
+}
+
+fn error_job_list_view(config: &RuntimeConfig, error: DaemonRpcError) -> CommandOutput {
+ match error {
+ DaemonRpcError::Unconfigured(reason)
+ | DaemonRpcError::Unauthorized(reason)
+ | DaemonRpcError::MethodUnavailable(reason) => {
+ CommandOutput::unconfigured(CommandView::JobList(JobListView {
+ state: "unconfigured".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ count: 0,
+ reason: Some(reason),
+ jobs: Vec::new(),
+ actions: vec![
+ "set RADROOTS_RPC_BEARER_TOKEN in .env or your shell".to_owned(),
+ "start radrootsd with bridge ingress enabled".to_owned(),
+ ],
+ }))
+ }
+ DaemonRpcError::External(reason) => {
+ CommandOutput::external_unavailable(CommandView::JobList(JobListView {
+ state: "unavailable".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ count: 0,
+ reason: Some(reason),
+ jobs: Vec::new(),
+ actions: vec!["start radrootsd and verify the rpc url".to_owned()],
+ }))
+ }
+ DaemonRpcError::InvalidResponse(reason)
+ | DaemonRpcError::Remote(reason)
+ | DaemonRpcError::UnknownJob(reason) => {
+ CommandOutput::internal_error(CommandView::JobList(JobListView {
+ state: "error".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ count: 0,
+ reason: Some(reason),
+ jobs: Vec::new(),
+ actions: Vec::new(),
+ }))
+ }
+ }
+}
+
+fn error_job_get_view(
+ config: &RuntimeConfig,
+ job_id: &str,
+ error: DaemonRpcError,
+) -> CommandOutput {
+ match error {
+ DaemonRpcError::Unconfigured(reason)
+ | DaemonRpcError::Unauthorized(reason)
+ | DaemonRpcError::MethodUnavailable(reason) => {
+ CommandOutput::unconfigured(CommandView::JobGet(JobGetView {
+ state: "unconfigured".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ lookup: job_id.to_owned(),
+ reason: Some(reason),
+ job: None,
+ actions: vec![
+ "set RADROOTS_RPC_BEARER_TOKEN in .env or your shell".to_owned(),
+ "start radrootsd with bridge ingress enabled".to_owned(),
+ ],
+ }))
+ }
+ DaemonRpcError::External(reason) => {
+ CommandOutput::external_unavailable(CommandView::JobGet(JobGetView {
+ state: "unavailable".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ lookup: job_id.to_owned(),
+ reason: Some(reason),
+ job: None,
+ actions: vec!["start radrootsd and verify the rpc url".to_owned()],
+ }))
+ }
+ DaemonRpcError::InvalidResponse(reason)
+ | DaemonRpcError::Remote(reason)
+ | DaemonRpcError::UnknownJob(reason) => {
+ CommandOutput::internal_error(CommandView::JobGet(JobGetView {
+ state: "error".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ lookup: job_id.to_owned(),
+ reason: Some(reason),
+ job: None,
+ actions: Vec::new(),
+ }))
+ }
+ }
+}
+
+fn error_job_watch_view(
+ config: &RuntimeConfig,
+ args: &JobWatchArgs,
+ frames: Vec<JobWatchFrameView>,
+ error: DaemonRpcError,
+) -> CommandOutput {
+ match error {
+ DaemonRpcError::Unconfigured(reason)
+ | DaemonRpcError::Unauthorized(reason)
+ | DaemonRpcError::MethodUnavailable(reason) => {
+ CommandOutput::unconfigured(CommandView::JobWatch(JobWatchView {
+ state: "unconfigured".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ job_id: args.key.clone(),
+ interval_ms: args.interval_ms,
+ reason: Some(reason),
+ frames,
+ actions: vec![
+ "set RADROOTS_RPC_BEARER_TOKEN in .env or your shell".to_owned(),
+ "start radrootsd with bridge ingress enabled".to_owned(),
+ ],
+ }))
+ }
+ DaemonRpcError::External(reason) => {
+ CommandOutput::external_unavailable(CommandView::JobWatch(JobWatchView {
+ state: "unavailable".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ job_id: args.key.clone(),
+ interval_ms: args.interval_ms,
+ reason: Some(reason),
+ frames,
+ actions: vec!["start radrootsd and verify the rpc url".to_owned()],
+ }))
+ }
+ DaemonRpcError::InvalidResponse(reason)
+ | DaemonRpcError::Remote(reason)
+ | DaemonRpcError::UnknownJob(reason) => {
+ CommandOutput::internal_error(CommandView::JobWatch(JobWatchView {
+ state: "error".to_owned(),
+ source: daemon::bridge_source().to_owned(),
+ rpc_url: config.rpc.url.clone(),
+ job_id: args.key.clone(),
+ interval_ms: args.interval_ms,
+ reason: Some(reason),
+ frames,
+ actions: Vec::new(),
+ }))
+ }
+ }
+}
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -24,8 +24,8 @@ use serde_json::Value;
use crate::cli::{ListingFileArgs, ListingNewArgs, RecordKeyArgs};
use crate::domain::runtime::{
- FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView,
- ListingNewView, ListingValidationIssueView, ListingValidateView, SyncFreshnessView,
+ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingNewView,
+ ListingValidateView, ListingValidationIssueView, SyncFreshnessView,
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts;
@@ -431,8 +431,9 @@ pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<ListingGetVie
}
fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeError> {
- let toml = toml::to_string_pretty(draft)
- .map_err(|error| RuntimeError::Config(format!("failed to render listing draft: {error}")))?;
+ let toml = toml::to_string_pretty(draft).map_err(|error| {
+ RuntimeError::Config(format!("failed to render listing draft: {error}"))
+ })?;
Ok(format!(
"# radroots listing draft v1\n# fill the empty fields, then run `radroots listing validate <file>`\n\n{toml}"
))
@@ -527,11 +528,13 @@ fn canonicalize_draft(
let quantity = RadrootsCoreQuantity::new(quantity_amount, quantity_unit)
.with_optional_label(non_empty(draft.primary_bin.label.clone()))
.to_canonical()
- .map_err(|error| issue_for_field(
- contents,
- "primary_bin.quantity_unit",
- format!("invalid primary_bin quantity unit conversion: {error}"),
- ))?;
+ .map_err(|error| {
+ issue_for_field(
+ contents,
+ "primary_bin.quantity_unit",
+ format!("invalid primary_bin quantity unit conversion: {error}"),
+ )
+ })?;
let price_amount = parse_decimal_field(
draft.primary_bin.price_amount.as_str(),
@@ -739,49 +742,37 @@ fn issue_from_trade_validation(
"listing.seller_pubkey",
"listing author does not match the farm pubkey",
),
- RadrootsTradeListingValidationError::MissingTitle => issue_for_field(
- contents,
- "product.title",
- "missing listing title",
- ),
- RadrootsTradeListingValidationError::MissingDescription => issue_for_field(
- contents,
- "product.summary",
- "missing listing description",
- ),
- RadrootsTradeListingValidationError::MissingProductType => issue_for_field(
- contents,
- "product.category",
- "missing listing product type",
- ),
+ RadrootsTradeListingValidationError::MissingTitle => {
+ issue_for_field(contents, "product.title", "missing listing title")
+ }
+ RadrootsTradeListingValidationError::MissingDescription => {
+ issue_for_field(contents, "product.summary", "missing listing description")
+ }
+ RadrootsTradeListingValidationError::MissingProductType => {
+ issue_for_field(contents, "product.category", "missing listing product type")
+ }
RadrootsTradeListingValidationError::MissingBins
| RadrootsTradeListingValidationError::MissingPrimaryBin
- | RadrootsTradeListingValidationError::InvalidBin => issue_for_field(
- contents,
- "primary_bin.bin_id",
- error.to_string(),
- ),
+ | RadrootsTradeListingValidationError::InvalidBin => {
+ issue_for_field(contents, "primary_bin.bin_id", error.to_string())
+ }
RadrootsTradeListingValidationError::InvalidPrice => issue_for_field(
contents,
"primary_bin.price_amount",
"invalid listing price",
),
RadrootsTradeListingValidationError::MissingInventory
- | RadrootsTradeListingValidationError::InvalidInventory => issue_for_field(
- contents,
- "inventory.available",
- error.to_string(),
- ),
+ | RadrootsTradeListingValidationError::InvalidInventory => {
+ issue_for_field(contents, "inventory.available", error.to_string())
+ }
RadrootsTradeListingValidationError::MissingAvailability => issue_for_field(
contents,
"availability.status",
"missing listing availability",
),
- RadrootsTradeListingValidationError::MissingLocation => issue_for_field(
- contents,
- "location.primary",
- "missing listing location",
- ),
+ RadrootsTradeListingValidationError::MissingLocation => {
+ issue_for_field(contents, "location.primary", "missing listing location")
+ }
RadrootsTradeListingValidationError::MissingDeliveryMethod => issue_for_field(
contents,
"delivery.method",
@@ -795,8 +786,7 @@ fn query_listing_rows(
executor: &SqliteExecutor,
lookup: &str,
) -> Result<Vec<ListingRow>, RuntimeError> {
- let sql =
- "SELECT tp.id, tp.key, tp.category, tp.title, tp.summary, tp.qty_amt, tp.qty_unit, tp.qty_label, tp.qty_avail, tp.price_amt, tp.price_currency, tp.price_qty_amt, tp.price_qty_unit, loc.location_primary \
+ let sql = "SELECT tp.id, tp.key, tp.category, tp.title, tp.summary, tp.qty_amt, tp.qty_unit, tp.qty_label, tp.qty_avail, tp.price_amt, tp.price_currency, tp.price_qty_amt, tp.price_qty_unit, loc.location_primary \
FROM trade_product tp \
LEFT JOIN (\
SELECT tpl.tb_tp AS trade_product_id, MIN(COALESCE(gl.label, gl.gc_name, gl.gc_admin1_name, gl.gc_country_name, gl.d_tag)) AS location_primary \
@@ -853,7 +843,11 @@ fn parse_unit_field(
field: &str,
) -> Result<RadrootsCoreUnit, ListingValidationIssueView> {
value.parse::<RadrootsCoreUnit>().map_err(|_| {
- issue_for_field(contents, field, format!("`{field}` must be a valid unit code"))
+ issue_for_field(
+ contents,
+ field,
+ format!("`{field}` must be a valid unit code"),
+ )
})
}
@@ -948,8 +942,7 @@ fn generate_d_tag() -> String {
}
fn encode_base64url_no_pad(bytes: [u8; 16]) -> String {
- const ALPHABET: &[u8; 64] =
- b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+ const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut output = String::with_capacity(22);
let mut index = 0usize;
while index + 3 <= bytes.len() {
diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs
@@ -1,6 +1,8 @@
pub mod accounts;
pub mod config;
+pub mod daemon;
pub mod find;
+pub mod job;
pub mod listing;
pub mod local;
pub mod logging;
diff --git a/tests/doctor.rs b/tests/doctor.rs
@@ -24,6 +24,8 @@ fn doctor_command_in(workdir: &Path) -> Command {
"RADROOTS_SIGNER",
"RADROOTS_RELAYS",
"RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
] {
command.env_remove(key);
}
diff --git a/tests/find.rs b/tests/find.rs
@@ -24,6 +24,8 @@ fn cli_command_in(workdir: &Path) -> Command {
"RADROOTS_SIGNER",
"RADROOTS_RELAYS",
"RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
] {
command.env_remove(key);
}
diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs
@@ -23,6 +23,8 @@ fn cli_command_in(workdir: &Path) -> Command {
"RADROOTS_SIGNER",
"RADROOTS_RELAYS",
"RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
] {
command.env_remove(key);
}
diff --git a/tests/job_rpc.rs b/tests/job_rpc.rs
@@ -0,0 +1,558 @@
+use std::io::{Read, Write};
+use std::net::{TcpListener, TcpStream};
+use std::path::Path;
+use std::process::Command;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+use std::thread::{self, JoinHandle};
+use std::time::Duration;
+
+use assert_cmd::prelude::*;
+use serde_json::{Value, json};
+use tempfile::tempdir;
+
+fn job_rpc_command_in(workdir: &Path) -> Command {
+ let mut command = Command::cargo_bin("radroots").expect("binary");
+ command.current_dir(workdir);
+ command.env("HOME", workdir.join("home"));
+ for key in [
+ "RADROOTS_ENV_FILE",
+ "RADROOTS_OUTPUT",
+ "RADROOTS_CLI_LOGGING_FILTER",
+ "RADROOTS_CLI_LOGGING_OUTPUT_DIR",
+ "RADROOTS_CLI_LOGGING_STDOUT",
+ "RADROOTS_LOG_FILTER",
+ "RADROOTS_LOG_DIR",
+ "RADROOTS_LOG_STDOUT",
+ "RADROOTS_ACCOUNT",
+ "RADROOTS_IDENTITY_PATH",
+ "RADROOTS_SIGNER",
+ "RADROOTS_RELAYS",
+ "RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
+ ] {
+ command.env_remove(key);
+ }
+ command
+}
+
+#[derive(Debug, Clone)]
+struct MockRpcRequest {
+ method: String,
+ auth_header: Option<String>,
+}
+
+#[derive(Debug, Clone)]
+struct MockRpcResponse {
+ status_code: u16,
+ body: Value,
+}
+
+impl MockRpcResponse {
+ fn success(result: Value) -> Self {
+ Self {
+ status_code: 200,
+ body: json!({
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": result,
+ }),
+ }
+ }
+
+ fn rpc_error(code: i64, message: &str) -> Self {
+ Self {
+ status_code: 200,
+ body: json!({
+ "jsonrpc": "2.0",
+ "id": 1,
+ "error": {
+ "code": code,
+ "message": message,
+ }
+ }),
+ }
+ }
+}
+
+struct MockRpcServer {
+ address: String,
+ shutdown: Arc<AtomicBool>,
+ handle: Option<JoinHandle<()>>,
+}
+
+impl MockRpcServer {
+ fn start<F>(handler: F) -> Self
+ where
+ F: Fn(String, Option<String>) -> MockRpcResponse + Send + Sync + 'static,
+ {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock rpc listener");
+ listener
+ .set_nonblocking(true)
+ .expect("set mock rpc listener nonblocking");
+ let address = listener
+ .local_addr()
+ .expect("mock rpc local addr")
+ .to_string();
+ let shutdown = Arc::new(AtomicBool::new(false));
+ let shutdown_flag = Arc::clone(&shutdown);
+ let handler: Arc<dyn Fn(String, Option<String>) -> MockRpcResponse + Send + Sync> =
+ Arc::new(handler);
+ let handle = thread::spawn(move || {
+ while !shutdown_flag.load(Ordering::SeqCst) {
+ match listener.accept() {
+ Ok((mut stream, _)) => {
+ if let Ok(request) = read_request(&mut stream) {
+ let response =
+ handler(request.method.clone(), request.auth_header.clone());
+ let _ = write_response(&mut stream, &response);
+ }
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(10));
+ }
+ Err(_) => break,
+ }
+ }
+ });
+
+ Self {
+ address,
+ shutdown,
+ handle: Some(handle),
+ }
+ }
+
+ fn url(&self) -> String {
+ format!("http://{}", self.address)
+ }
+}
+
+impl Drop for MockRpcServer {
+ fn drop(&mut self) {
+ self.shutdown.store(true, Ordering::SeqCst);
+ let _ = TcpStream::connect(&self.address);
+ if let Some(handle) = self.handle.take() {
+ handle.join().expect("join mock rpc server thread");
+ }
+ }
+}
+
+fn read_request(stream: &mut TcpStream) -> Result<MockRpcRequest, String> {
+ stream
+ .set_read_timeout(Some(Duration::from_secs(2)))
+ .map_err(|error| format!("set mock rpc read timeout: {error}"))?;
+
+ let mut buffer = Vec::new();
+ let mut chunk = [0_u8; 4096];
+ let mut header_end = None;
+ let mut content_length = 0_usize;
+
+ loop {
+ let read = stream
+ .read(&mut chunk)
+ .map_err(|error| format!("read mock rpc request: {error}"))?;
+ if read == 0 {
+ break;
+ }
+ buffer.extend_from_slice(&chunk[..read]);
+ if header_end.is_none() {
+ header_end = find_subslice(&buffer, b"\r\n\r\n").map(|index| index + 4);
+ if let Some(end) = header_end {
+ content_length = parse_content_length(&buffer[..end])?;
+ if buffer.len() >= end + content_length {
+ break;
+ }
+ }
+ } else if let Some(end) = header_end {
+ if buffer.len() >= end + content_length {
+ break;
+ }
+ }
+ }
+
+ let end = header_end.ok_or_else(|| "mock rpc request did not include headers".to_owned())?;
+ let headers = std::str::from_utf8(&buffer[..end])
+ .map_err(|error| format!("mock rpc headers were not utf-8: {error}"))?;
+ let auth_header = parse_header(headers, "authorization");
+ let body = std::str::from_utf8(&buffer[end..end + content_length])
+ .map_err(|error| format!("mock rpc body was not utf-8: {error}"))?;
+ let envelope: Value =
+ serde_json::from_str(body).map_err(|error| format!("parse mock rpc json body: {error}"))?;
+ let method = envelope["method"]
+ .as_str()
+ .ok_or_else(|| "mock rpc body did not include method".to_owned())?
+ .to_owned();
+
+ Ok(MockRpcRequest {
+ method,
+ auth_header,
+ })
+}
+
+fn parse_content_length(headers: &[u8]) -> Result<usize, String> {
+ let text = std::str::from_utf8(headers)
+ .map_err(|error| format!("mock rpc header parse failed: {error}"))?;
+ for line in text.lines() {
+ if let Some((name, value)) = line.split_once(':') {
+ if name.trim().eq_ignore_ascii_case("content-length") {
+ return value
+ .trim()
+ .parse::<usize>()
+ .map_err(|error| format!("mock rpc content-length parse failed: {error}"));
+ }
+ }
+ }
+ Ok(0)
+}
+
+fn parse_header(headers: &str, wanted: &str) -> Option<String> {
+ headers.lines().find_map(|line| {
+ let (name, value) = line.split_once(':')?;
+ if name.trim().eq_ignore_ascii_case(wanted) {
+ Some(value.trim().to_owned())
+ } else {
+ None
+ }
+ })
+}
+
+fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
+ haystack
+ .windows(needle.len())
+ .position(|window| window == needle)
+}
+
+fn write_response(stream: &mut TcpStream, response: &MockRpcResponse) -> Result<(), String> {
+ let body = response.body.to_string();
+ write!(
+ stream,
+ "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+ response.status_code,
+ status_text(response.status_code),
+ body.len(),
+ body
+ )
+ .map_err(|error| format!("write mock rpc response: {error}"))?;
+ stream
+ .flush()
+ .map_err(|error| format!("flush mock rpc response: {error}"))
+}
+
+fn status_text(status_code: u16) -> &'static str {
+ match status_code {
+ 200 => "OK",
+ 401 => "Unauthorized",
+ 500 => "Internal Server Error",
+ _ => "OK",
+ }
+}
+
+fn sample_bridge_status() -> Value {
+ json!({
+ "enabled": true,
+ "ready": true,
+ "auth_mode": "bearer_token",
+ "signer_mode": "selectable_per_request",
+ "default_signer_mode": "embedded_service_identity",
+ "supported_signer_modes": ["embedded_service_identity", "nip46_session"],
+ "available_nip46_signer_sessions": 2,
+ "relay_count": 3,
+ "job_status_retention": 32,
+ "retained_jobs": 1,
+ "accepted_jobs": 4,
+ "published_jobs": 3,
+ "failed_jobs": 1,
+ "recovered_failed_jobs": 0,
+ "methods": ["bridge.status", "bridge.job.list", "bridge.job.status", "nip46.session.list"]
+ })
+}
+
+fn sample_job(job_id: &str, state: &str, terminal: bool, completed_at_unix: Option<u64>) -> Value {
+ json!({
+ "job_id": job_id,
+ "command": "bridge.listing.publish",
+ "status": state,
+ "terminal": terminal,
+ "recovered_after_restart": false,
+ "requested_at_unix": 1_712_720_000,
+ "completed_at_unix": completed_at_unix,
+ "signer_mode": "embedded_service_identity",
+ "event_id": "event-123",
+ "event_addr": "30023:npub1seller:listing-123",
+ "delivery_policy": "best_effort",
+ "delivery_quorum": 2,
+ "relay_count": 3,
+ "acknowledged_relay_count": if terminal { 2 } else { 1 },
+ "required_acknowledged_relay_count": 2,
+ "attempt_count": if terminal { 2 } else { 1 },
+ "relay_outcome_summary": if terminal { "published to 2 relays" } else { "awaiting quorum" },
+ "attempt_summaries": if terminal {
+ json!(["attempt 1: relay.one accepted", "attempt 2: relay.two accepted"])
+ } else {
+ json!(["attempt 1: relay.one accepted"])
+ }
+ })
+}
+
+#[test]
+fn rpc_status_reports_bridge_ready_via_daemon_rpc() {
+ let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new()));
+ let recorded = Arc::clone(&requests);
+ let server = MockRpcServer::start(move |method, auth_header| {
+ recorded
+ .lock()
+ .expect("record requests")
+ .push(MockRpcRequest {
+ method: method.clone(),
+ auth_header: auth_header.clone(),
+ });
+ match method.as_str() {
+ "bridge.status" => MockRpcResponse::success(sample_bridge_status()),
+ _ => MockRpcResponse::rpc_error(-32601, "method not found"),
+ }
+ });
+
+ let dir = tempdir().expect("tempdir");
+ let output = job_rpc_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "secret")
+ .args(["--json", "rpc", "status"])
+ .output()
+ .expect("run rpc status");
+
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output");
+ assert_eq!(json["state"], "ready");
+ assert_eq!(json["url"], server.url());
+ assert_eq!(json["bridge_ready"], true);
+ assert_eq!(json["retained_jobs"], 1);
+ assert_eq!(json["session_surface_enabled"], true);
+
+ let recorded = requests.lock().expect("recorded requests");
+ assert_eq!(recorded.len(), 1);
+ assert_eq!(recorded[0].method, "bridge.status");
+ assert_eq!(recorded[0].auth_header.as_deref(), Some("Bearer secret"));
+}
+
+#[test]
+fn rpc_sessions_ndjson_emits_public_session_entries() {
+ let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new()));
+ let recorded = Arc::clone(&requests);
+ let server = MockRpcServer::start(move |method, auth_header| {
+ recorded
+ .lock()
+ .expect("record requests")
+ .push(MockRpcRequest {
+ method: method.clone(),
+ auth_header: auth_header.clone(),
+ });
+ match method.as_str() {
+ "nip46.session.list" => MockRpcResponse::success(json!([
+ {
+ "session_id": "session-1",
+ "role": "client",
+ "client_pubkey": "client-1",
+ "signer_pubkey": "signer-1",
+ "user_pubkey": "user-1",
+ "relays": ["wss://relay.one"],
+ "permissions": ["sign_event"],
+ "auth_required": false,
+ "authorized": true,
+ "expires_in_secs": 60
+ },
+ {
+ "session_id": "session-2",
+ "role": "admin",
+ "client_pubkey": "client-2",
+ "signer_pubkey": "signer-2",
+ "user_pubkey": null,
+ "relays": ["wss://relay.two", "wss://relay.three"],
+ "permissions": ["describe"],
+ "auth_required": true,
+ "authorized": false,
+ "expires_in_secs": null
+ }
+ ])),
+ _ => MockRpcResponse::rpc_error(-32601, "method not found"),
+ }
+ });
+
+ let dir = tempdir().expect("tempdir");
+ let output = job_rpc_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .args(["--ndjson", "rpc", "sessions"])
+ .output()
+ .expect("run rpc sessions");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let lines = stdout.lines().collect::<Vec<_>>();
+ assert_eq!(lines.len(), 2);
+ assert!(lines[0].contains("\"session_id\":\"session-1\""));
+ assert!(lines[1].contains("\"authorized\":false"));
+
+ let recorded = requests.lock().expect("recorded requests");
+ assert_eq!(recorded.len(), 1);
+ assert_eq!(recorded[0].method, "nip46.session.list");
+ assert_eq!(recorded[0].auth_header, None);
+}
+
+#[test]
+fn job_commands_require_bridge_bearer_token() {
+ let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new()));
+ let recorded = Arc::clone(&requests);
+ let server = MockRpcServer::start(move |method, auth_header| {
+ recorded
+ .lock()
+ .expect("record requests")
+ .push(MockRpcRequest {
+ method,
+ auth_header,
+ });
+ MockRpcResponse::rpc_error(-32601, "method not found")
+ });
+
+ let dir = tempdir().expect("tempdir");
+ let output = job_rpc_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .args(["--json", "job", "ls"])
+ .output()
+ .expect("run job ls");
+
+ assert_eq!(output.status.code(), Some(3));
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output");
+ assert_eq!(json["state"], "unconfigured");
+ assert!(
+ json["reason"]
+ .as_str()
+ .expect("reason")
+ .contains("bridge bearer token is not configured")
+ );
+ assert!(requests.lock().expect("recorded requests").is_empty());
+}
+
+#[test]
+fn job_ls_and_get_report_retained_bridge_jobs() {
+ let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new()));
+ let recorded = Arc::clone(&requests);
+ let server = MockRpcServer::start(move |method, auth_header| {
+ recorded
+ .lock()
+ .expect("record requests")
+ .push(MockRpcRequest {
+ method: method.clone(),
+ auth_header: auth_header.clone(),
+ });
+ match method.as_str() {
+ "bridge.job.list" => {
+ MockRpcResponse::success(json!([sample_job("job-123", "publishing", false, None)]))
+ }
+ "bridge.job.status" => MockRpcResponse::success(sample_job(
+ "job-123",
+ "published",
+ true,
+ Some(1_712_720_030),
+ )),
+ _ => MockRpcResponse::rpc_error(-32601, "method not found"),
+ }
+ });
+
+ let dir = tempdir().expect("tempdir");
+ let list = job_rpc_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "secret")
+ .args(["--json", "job", "ls"])
+ .output()
+ .expect("run job ls");
+ assert!(list.status.success());
+ let list_json: Value = serde_json::from_slice(list.stdout.as_slice()).expect("list json");
+ assert_eq!(list_json["state"], "ready");
+ assert_eq!(list_json["count"], 1);
+ assert_eq!(list_json["jobs"][0]["id"], "job-123");
+ assert_eq!(list_json["jobs"][0]["command"], "listing.publish");
+
+ let get = job_rpc_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "secret")
+ .args(["--json", "job", "get", "job-123"])
+ .output()
+ .expect("run job get");
+ assert!(get.status.success());
+ let get_json: Value = serde_json::from_slice(get.stdout.as_slice()).expect("get json");
+ assert_eq!(get_json["state"], "ready");
+ assert_eq!(get_json["job"]["id"], "job-123");
+ assert_eq!(
+ get_json["job"]["relay_outcome_summary"],
+ "published to 2 relays"
+ );
+
+ let recorded = requests.lock().expect("recorded requests");
+ assert_eq!(recorded.len(), 2);
+ assert!(
+ recorded
+ .iter()
+ .all(|request| request.auth_header.as_deref() == Some("Bearer secret"))
+ );
+}
+
+#[test]
+fn job_watch_ndjson_emits_one_frame_per_poll_until_terminal() {
+ let sequence = Arc::new(Mutex::new(0_usize));
+ let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new()));
+ let observed = Arc::clone(&requests);
+ let counter = Arc::clone(&sequence);
+ let server = MockRpcServer::start(move |method, auth_header| {
+ observed
+ .lock()
+ .expect("record requests")
+ .push(MockRpcRequest {
+ method: method.clone(),
+ auth_header,
+ });
+ match method.as_str() {
+ "bridge.job.status" => {
+ let mut count = counter.lock().expect("watch count");
+ *count += 1;
+ if *count == 1 {
+ MockRpcResponse::success(sample_job("job-123", "publishing", false, None))
+ } else {
+ MockRpcResponse::success(sample_job(
+ "job-123",
+ "published",
+ true,
+ Some(1_712_720_030),
+ ))
+ }
+ }
+ _ => MockRpcResponse::rpc_error(-32601, "method not found"),
+ }
+ });
+
+ let dir = tempdir().expect("tempdir");
+ let output = job_rpc_command_in(dir.path())
+ .env("RADROOTS_RPC_URL", server.url())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "secret")
+ .args([
+ "--ndjson",
+ "job",
+ "watch",
+ "job-123",
+ "--frames",
+ "3",
+ "--interval-ms",
+ "1",
+ ])
+ .output()
+ .expect("run job watch");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let lines = stdout.lines().collect::<Vec<_>>();
+ assert_eq!(lines.len(), 2);
+ assert!(lines[0].contains("\"sequence\":1"));
+ assert!(lines[0].contains("\"state\":\"publishing\""));
+ assert!(lines[1].contains("\"sequence\":2"));
+ assert!(lines[1].contains("\"terminal\":true"));
+}
diff --git a/tests/listing.rs b/tests/listing.rs
@@ -25,6 +25,8 @@ fn cli_command_in(workdir: &Path) -> Command {
"RADROOTS_SIGNER",
"RADROOTS_RELAYS",
"RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
] {
command.env_remove(key);
}
@@ -50,9 +52,7 @@ fn listing_new_scaffolds_a_toml_draft_with_account_and_farm_defaults() {
let seller_pubkey = account_json["public_identity"]["public_key_hex"]
.as_str()
.expect("seller pubkey");
- let account_id = account_json["account"]["id"]
- .as_str()
- .expect("account id");
+ let account_id = account_json["account"]["id"].as_str().expect("account id");
let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw";
seed_farm(dir.path(), seller_pubkey, farm_d_tag, "La Huerta");
diff --git a/tests/local.rs b/tests/local.rs
@@ -24,6 +24,8 @@ fn local_command_in(workdir: &Path) -> Command {
"RADROOTS_SIGNER",
"RADROOTS_RELAYS",
"RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
] {
command.env_remove(key);
}
diff --git a/tests/myc_status.rs b/tests/myc_status.rs
@@ -27,6 +27,8 @@ fn cli_command_in(workdir: &Path) -> Command {
"RADROOTS_SIGNER",
"RADROOTS_RELAYS",
"RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
] {
command.env_remove(key);
}
diff --git a/tests/relay_net.rs b/tests/relay_net.rs
@@ -24,6 +24,8 @@ fn cli_command_in(workdir: &Path) -> Command {
"RADROOTS_SIGNER",
"RADROOTS_RELAYS",
"RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
] {
command.env_remove(key);
}
diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs
@@ -24,6 +24,8 @@ fn runtime_show_command_in(workdir: &Path) -> Command {
"RADROOTS_SIGNER",
"RADROOTS_RELAYS",
"RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
] {
command.env_remove(key);
}
@@ -114,6 +116,8 @@ fn config_show_json_reports_default_bootstrap_state() {
.to_string()
);
assert_eq!(json["myc"]["executable"], "myc");
+ assert_eq!(json["rpc"]["url"], "http://127.0.0.1:7070");
+ assert_eq!(json["rpc"]["bridge_auth_configured"], false);
}
#[test]
@@ -129,6 +133,8 @@ fn config_show_json_reflects_environment_configuration() {
.env("RADROOTS_SIGNER", "myc")
.env("RADROOTS_RELAYS", "wss://relay.one,wss://relay.two")
.env("RADROOTS_MYC_EXECUTABLE", "bin/myc")
+ .env("RADROOTS_RPC_URL", "https://rpc.radroots.test/jsonrpc")
+ .env("RADROOTS_RPC_BEARER_TOKEN", "secret")
.args(["config", "show"])
.output()
.expect("run config show");
@@ -149,6 +155,8 @@ fn config_show_json_reflects_environment_configuration() {
assert_eq!(json["relay"]["urls"][0], "wss://relay.one");
assert_eq!(json["relay"]["source"], "environment ยท local first");
assert_eq!(json["myc"]["executable"], "bin/myc");
+ assert_eq!(json["rpc"]["url"], "https://rpc.radroots.test/jsonrpc");
+ assert_eq!(json["rpc"]["bridge_auth_configured"], true);
}
#[test]
@@ -232,6 +240,28 @@ fn config_show_json_reads_workspace_relay_config() {
}
#[test]
+fn config_show_reads_workspace_rpc_config() {
+ let dir = tempdir().expect("tempdir");
+ let config_dir = dir.path().join(".radroots");
+ fs::create_dir_all(&config_dir).expect("workspace config dir");
+ fs::write(
+ config_dir.join("config.toml"),
+ "[rpc]\nurl = \"https://rpc.workspace.test/jsonrpc\"\n",
+ )
+ .expect("write workspace config");
+
+ let output = runtime_show_command_in(dir.path())
+ .args(["--json", "config", "show"])
+ .output()
+ .expect("run config show");
+
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output");
+ assert_eq!(json["rpc"]["url"], "https://rpc.workspace.test/jsonrpc");
+ assert_eq!(json["rpc"]["bridge_auth_configured"], false);
+}
+
+#[test]
fn config_show_rejects_ndjson_for_singular_output() {
let dir = tempdir().expect("tempdir");
let output = runtime_show_command_in(dir.path())
diff --git a/tests/signer_status.rs b/tests/signer_status.rs
@@ -24,6 +24,8 @@ fn cli_command_in(workdir: &Path) -> Command {
"RADROOTS_SIGNER",
"RADROOTS_RELAYS",
"RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
] {
command.env_remove(key);
}
diff --git a/tests/sync.rs b/tests/sync.rs
@@ -24,6 +24,8 @@ fn cli_command_in(workdir: &Path) -> Command {
"RADROOTS_SIGNER",
"RADROOTS_RELAYS",
"RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
] {
command.env_remove(key);
}