cli

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

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:
MCargo.lock | 720+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCargo.toml | 2++
Msrc/cli.rs | 48++++++++++++++++++++++++++++++++++++++++++------
Asrc/commands/job.rs | 16++++++++++++++++
Msrc/commands/mod.rs | 12+++++++-----
Asrc/commands/rpc.rs | 10++++++++++
Msrc/commands/runtime.rs | 6+++++-
Msrc/domain/runtime.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 317++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/runtime/config.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/runtime/daemon.rs | 498+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/runtime/job.rs | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/listing.rs | 81++++++++++++++++++++++++++++++++++++-------------------------------------------
Msrc/runtime/mod.rs | 2++
Mtests/doctor.rs | 2++
Mtests/find.rs | 2++
Mtests/identity_commands.rs | 2++
Atests/job_rpc.rs | 558+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/listing.rs | 6+++---
Mtests/local.rs | 2++
Mtests/myc_status.rs | 2++
Mtests/relay_net.rs | 2++
Mtests/runtime_show.rs | 30++++++++++++++++++++++++++++++
Mtests/signer_status.rs | 2++
Mtests/sync.rs | 2++
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); }